Compare commits

...
52 Commits
Author SHA1 Message Date
Gustavo SantosandClaude Sonnet 5.5 9f32c84b0c chore: allow versioned SQL source files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:28:21 -03:00
Gustavo Santos d21c4596e1 chore: import relatorio (subtree)
git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
2026-10-01 14:26:02 -03:00
Gustavo Santos e322cec92b chore: import coordenador (subtree)
git-subtree-dir: coordenador
git-subtree-mainline: facbe2e340
git-subtree-split: ce7af94d55
2026-10-01 14:25:55 -03:00
Gustavo SantosandClaude Sonnet 5.5 facbe2e340 chore: initialize Slack coordinator reports monorepo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:25:48 -03:00
Automacao GLPIandClaude Sonnet 5.5 34bcb132f3 chore: remove superseded legacy workflows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:23:59 -03:00
GustavoandClaude Sonnet 5.5 ce7af94d55 docs: update coordinator project state
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:23:46 -03:00
Automacao GLPIandClaude Sonnet 5 24f0476dd7 Atualiza README para refletir a V2 em producao (V1 vira referencia legado)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:15:34 -03:00
Automacao GLPIandClaude Sonnet 5 9475e68cb1 Corrige bug real de idempotencia: message_hash nao deve fazer parte do send_key
Bug encontrado AO VIVO durante o teste controlado de envio (secao 14): a
IA (gpt-5.5) nao e deterministica. Duas chamadas para o MESMO periodo
(mesmo report_type+data) geraram insights com textos diferentes, portanto
message_hash diferente, portanto send_key diferente — o retry do MESMO
periodo passou pela deteccao de duplicata como se fosse outro relatorio.

Resultado real: 3 mensagens verdadeiras enviadas ao canal C0BJNBTPFT5
para o periodo 'diario 2026-08-24' antes de eu interromper e corrigir
(execucoes 16744, 16746 e uma terceira ja em voo quando a guarda foi
revertida). Estado revertido imediatamente (DEV_MODE/SHADOW_MODE=true,
workflow desativado) assim que o padrao foi identificado.

Correcao: src/v2/sendGuard.js#buildSendKey agora usa so
report_type+period_start+period_end+destination (SEM message_hash) — a
identidade de um relatorio e o PERIODO, nao o texto exato produzido pela
IA. message_hash continua calculado e armazenado, so para diagnostico.

As 3 linhas reais gravadas na Data Table dev_glpi_v2_send_log foram
normalizadas manualmente (send_key sem sufixo de hash) para que a
protecao de duplicata funcione corretamente para qualquer nova tentativa
do mesmo periodo daqui pra frente.

Testes novos: sendGuard.test.js e pipeline.test.js ganharam testes de
regressao reproduzindo exatamente este cenario (retry do mesmo periodo
com aiCall/mensagem diferente -> send_key igual -> duplicata bloqueada).

Canal Slack real confirmado nesta sessao: D06JK4GEK8E (DM) foi tentado
primeiro e falhou com channel_not_found (bot sem acesso); o usuario optou
por reaproveitar C0BJNBTPFT5 (canal do workflow irmao Coordenador GLPI,
onde o bot ja e membro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:44:44 -03:00
Automacao GLPIandClaude Sonnet 5 f6f12c53d8 Corrige bug real: insert de log de envio (falha) quebrava por coluna 'erro' inexistente
Encontrado ao vivo durante o teste controlado de envio (secao 14): a Data
Table dev_glpi_v2_send_log tem schema fixo (imutavel apos criacao) sem
coluna 'erro'. O Code node 'Preparar Log de Envio (Falha)' incluia esse
campo, e o autoMapInputData tentava mapear para uma coluna que nao existe
-> 'Validation error with data table request: unknown column name erro'
(execucao 16724). O erro fica agora so no console.log do node, nao mais
no payload enviado a Data Table.

Confirmado no mesmo teste: nenhuma mensagem real chegou a ser enviada
(Slack node retornou channel_not_found e foi corretamente capturado pelo
onError:continueErrorOutput antes deste bug secundario aparecer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:34:58 -03:00
Automacao GLPIandClaude Sonnet 5 64ddfb9b7b V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo
- src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/
  SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente
  (secao 15 do ciclo final: matriz completa de guarda de seguranca).
  computeMessageHash/buildSendKey/isDuplicate implementam a chave
  logica de idempotencia da secao 13 (report_type+periodo+destino+hash).
- src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION.
- src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/
  motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType
  (.mode) e sendLogRows (Data Table de log de envios).
- tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios',
  troca do HTTP Slack com webhook placeholder por node Slack nativo
  (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao
  Coordenador GLPI), branches de sucesso/falha gravando o log de envio
  so apos confirmacao real do Slack.
- test/sendGuard.test.js, test/workflowStructure.test.js (novos):
  cobrem a matriz de guarda de seguranca e travam regressao do bug de
  timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug
  de sendGuard.js nao concatenado no Code node gerado.
- Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava
  na lista de modulos concatenados pelo gerador, deixando canSend/
  computeMessageHash/buildSendKey undefined em runtime.
- Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6):
  n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado
  nos 9 nodes de leitura em lote.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:22:42 -03:00
GustavoandClaude Sonnet 5 2724b90cb4 docs: correção final report (Validar e executar fix, determinism bug, #62/#75 audit)
Compact final report per mission format: root cause + fix for the
"Validar e executar" label bug, the critical Top10 non-determinism bug
(real duplicate-send root cause, proven fixed with identical message
hash across two consecutive real executions), #62/#75 real-data audit
(both legitimate, no ID-based filter warranted), Slack formatting
confirmation, schedule confirmation, production state confirmation.
Verdict: PRONTO PARA USO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 06:22:15 -03:00
GustavoandClaude Sonnet 5 b11fcb8a7f fix(v2): CRITICAL -- deterministic tie-break in Top10 selection (real duplicate-send root cause)
Found running the real double-send idempotency proof the mission
explicitly required: two executions seconds apart, essentially unchanged
GLPI data, produced TWO DIFFERENT Top10 compositions -- and since the
idempotency guard is keyed on the exact message hash, both were treated
as legitimately different digests and BOTH sent real Slack messages.

Root cause: logic/diversity.js's selectTop() sorted candidates by score
descending using Array.sort, which is stable but has no secondary key.
Many real candidates tie on score (e.g. several ACTIONABLE_NOW tickets
at 260 or 247.6 points), so ties were broken purely by whatever order
$input.all() happened to deliver items in -- and n8n does NOT guarantee
relative item order across parallel branches that re-merge (documented
elsewhere in this project's history: cache/ia/fallback classification
branches converge at Merge - Consolidar Classificacoes before reaching
here). Different arrival order -> different tie-break -> different
Top10 -> different message -> both "different enough" to bypass
idempotency, even though nothing meaningful changed in GLPI.

Fixed with a deterministic secondary sort key (ticket.id, ascending)
that only applies when scores are exactly equal -- doesn't change WHO
wins (still purely score-driven), only makes ties reproducible run to
run. This is a determinism fix, not a ranking/weight change (mission
section 21 constraint respected).

Added a regression test that reproduces the exact real-world scenario:
the same 12 candidates (all tied at score 200), fed in two different
orders, must produce the identical Top10.

171/171 tests passing.

Two real Slack messages were sent during this investigation (with the
user's standing test authorization) before the fix landed -- both
correctly formatted, no attribution footer, real diverse next_action
values, no literal backslash-n. Reverted SEND_ENABLED to false and
deactivated the workflow immediately after discovering the root cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:59:29 -03:00
GustavoandClaude Sonnet 5 124516e64a fix(v2): fix "Validar e executar" bug (real root cause), expand generic-phrase blacklist, add FORCE_SEND
Root cause found for why the real Slack message showed "→ Validar e
executar" for nearly every ACTIONABLE_NOW ticket: logic/message.js's
ACTION_LABELS map had a hardcoded entry for ACTIONABLE_NOW that always
won over the AI's actual next_action field in shortActionLabel(). The
AI's real, specific, already-validated-as-non-generic next_action (e.g.
"Realizar inventário das máquinas") was being silently discarded for
display purposes on every single message. Fixed by removing the
ACTIONABLE_NOW entry -- it now falls through to the existing
next_action-based fallback (already there, just unreachable for that
class before). UNASSIGNED_ACTIONABLE intentionally keeps its fixed
"Atribuir técnico" label -- that IS the correct display for that class.

logic/classification.js: expanded GENERIC_NEXT_ACTION_PHRASES with the
exact phrase that leaked through ("validar e executar") plus the
mission's additional list (validar chamado, prosseguir atendimento,
executar chamado, tratar chamado, analisar chamado). Bumped
AI_CLASSIFIER_VERSION 3->4 since this changes what next_action values
validate as non-generic -- forces reclassification of any cached
candidate that only passed under the older, narrower blacklist.

n8n/nodes/validate-ai-response.node.js: generic-next-action rejections
now get their own distinct fallback_reason ('generic_or_unsupported_
next_action') instead of being lumped into 'invalid_json', matching the
same pattern already used for the technician-vendor conflict.

logic/slack.js + logic/config.js: added FORCE_SEND (default false).
Manual-triggered runs now never send for real even with SEND_ENABLED=
true in production, unless FORCE_SEND is also explicitly set -- protects
against someone clicking "Execute Workflow" in production and
accidentally re-sending today's digest.

Added format-safety regression tests (no literal backslash-n, URL never
bleeds into the next item's id, no markdown-wrapped URLs) -- verified
these were never actually triggered in a real send (the reported
corruption was n8n's raw JSON data-inspector view, not a delivered
Slack message; both manual runs in question had shouldSend:false).

169/169 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:49:42 -03:00
GustavoandClaude Sonnet 5 26b06e07d5 feat(v2): REAL Slack send confirmed working end-to-end
After the user added the missing OAuth scopes (channels:join,
chat:write.public) and reinstalled the Slack App, updated the credential
with the refreshed token and retried the real test send: SUCCESS. The
digest message was actually posted to #chamados-suporte (channel
C0BJNBTPFT5) by "Bot Coordenador", confirmed via the real Slack API
response (ok:true, real message ts/channel/bot_profile returned).
Code - Registrar Envio Sucesso correctly recorded status:'success' --
this record is intentionally left in place (not cleared like the earlier
failed-test rows), since it now correctly represents a real send and its
whole purpose is to prevent a duplicate resend of today's digest.

Also found and fixed a real cosmetic bug from that same successful send:
n8n's Slack node appends an "Automated with this n8n workflow" link
footer by default (otherOptions.includeLinkToWorkflow defaults to true)
-- not part of the message spec (minimal digest only). Disabled it.
Added a regression test.

Reverted SEND_ENABLED to false and deactivated pending final user
confirmation before permanent production activation.

159/159 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:37:17 -03:00
GustavoandClaude Sonnet 5 18ff9129cf fix(v2): use exact Slack channel ID (from Slack's own UI), root-cause the send failure
Switched channelId from name-based lookup to the exact ID the user
pulled directly from Slack's channel details (C0BJNBTPFT5), eliminating
any ambiguity between similarly-named channels. Retested the real send:
still not_in_channel with the exact ID confirmed from Slack's own UI.

Diagnosed further with a temporary conversations.join attempt (same
credential, same channel) -- Slack returned a precise, actionable error:
"Your Slack credential is missing required Oauth Scopes: channels:join".
This confirms the token/credential itself authenticates correctly (not
a wrong-bot or wrong-workspace problem) -- the Slack App's OAuth scopes
are the real blocker: it has enough to attempt chat.postMessage but not
enough to self-join a channel, and per the earlier not_in_channel result,
also isn't a member and chat:write.public isn't granted either. This is
a Slack App admin-console change (api.slack.com/apps -> OAuth &
Permissions -> add channels:join and/or chat:write.public -> reinstall
app), not something fixable from n8n. Reported to the user with the
exact scope names needed.

Reverted SEND_ENABLED to false, workflow deactivated, both temp nodes
removed, failed test rows cleared from coordenador_glpi_envios_digest
per the user's request.

158/158 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:32:09 -03:00
GustavoandClaude Sonnet 5 3796bd738a fix(v2): confirmed real Slack channel slug (chamados-suporte)
Two more real test-send attempts (both with the user's standing
authorization), each confirming a bit more of the pipeline and each
correctly caught/recorded/surfaced by the failure branch -- no message
ever actually reached Slack, exactly as the guard is designed to behave
on failure:

1. channel "CHAMADOS SUPORTE" (as literally given) -> channel_not_found
   (Slack channel identifiers are always lowercase-hyphenated).
2. channel "chamados-suporte" -> not_in_channel: the channel resolved
   correctly this time, but the Bot Coordenador app is not yet a member
   of that channel (chat.postMessage requires channel membership without
   the chat:write.public scope). This is a Slack-side action for the
   user (invite the bot to the channel), not fixable from n8n config.

Updated the channel value to the confirmed-correct slug. Reverted
SEND_ENABLED to false, workflow deactivated, temp webhook removed.

158/158 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:15:04 -03:00
GustavoandClaude Sonnet 5 0c4a9482cb fix(v2): use the real Bot Coordenador Slack credential and CHAMADOS SUPORTE channel
The user provided the real, dedicated Slack destination for this
automation (a separate Bot app credential + its channel), correcting my
earlier inference (the shared "Slack account" credential + $vars.
SLACK_CHANNEL_TECNICO, borrowed from an unrelated archived workflow,
which Slack's own API rejected as invalid_arguments in the prior test).

Created a new slackApi credential ("Slack - Bot Coordenador (Chamados
Suporte)", id lNqwBBrtLfkRqw2X) via n8n_manage_credentials -- the token
was created directly through the n8n credentials API and never written
to any file, workflow parameter, or commit; only the credential's id/name
reference (not a secret) is stored in the workflow, same as every other
credential in this project. Slack - Enviar Mensagem now targets channel
"CHAMADOS SUPORTE" by name instead of the unresolved variable.

158/158 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:12:58 -03:00
GustavoandClaude Sonnet 5 71796390e3 docs: production-readiness final report
Classifier before/after (real execution 14404), critical-case table for
the 7 reincident tickets (technician-vs-vendor and contact-vs-waiting
fixes confirmed on real data), Top10 critical inspection, Slack
mechanism confirmation (real test send attempted with explicit user
authorization -- pipeline confirmed working end-to-end up to Slack's own
API, which rejected $vars.SLACK_CHANNEL_TECNICO as invalid), Schedule
Trigger confirmation, test/validation summary, production state
confirmation. Verdict: PRONTO PARA ATIVAÇÃO — BLOQUEADO APENAS POR
ACESSO (Slack channel configuration only, not code or n8n access).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:03:28 -03:00
GustavoandClaude Sonnet 5 617c67493f fix(v2): envio-digest insert nodes were missing the required columns mapping
Found running the real Slack test send (with the user's explicit
confirmation to try SEND_ENABLED=true once): the Slack node itself fired
correctly and got a real response back from Slack's API ("invalid_arguments"
-- confirms the pipeline mechanics, credential and node config work end
to end up to that point). Separately, once the failure branch tried to
record it, "Data Table - Registrar Envio Digest (Falha)" crashed with
"Cannot convert undefined or null to object" inside n8n's own insert
operation -- both new envio-digest insert nodes had operation:"insert"
and dataTableId but no "columns" mapping, which is a required property
for insert/update/upsert (confirmed via get_node nodes-base.dataTable).
Fixed both nodes with the same columns.mappingMode:"defineBelow" shape
already used by the existing "Data Table - Registrar Shadow Run" node.

Added a regression test scanning every dataTable node with an insert/
update/upsert operation for a columns.mappingMode.

158/158 tests passing.

Separately (not fixed here, needs the user): the actual Slack
"invalid_arguments" error suggests $vars.SLACK_CHANNEL_TECNICO isn't
resolving to a valid channel on this n8n instance -- no tool available
to this agent can list/inspect n8n instance Variables to confirm. Left
SEND_ENABLED=false and the workflow inactive pending the user's input on
the correct channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:02:05 -03:00
GustavoandClaude Sonnet 5 86039ecfe4 fix(v2): Data Table - Buscar Envios Recentes needs alwaysOutputData (real bug found in DEV validation)
Found running the real validation execution against DEV: the envios
digest table starts empty (the normal state for every day until the
first successful send), the "get" operation returned 0 rows, and n8n
does not execute downstream nodes when a node outputs 0 items -- so
Code - Guard de Envio Slack, the IF, the real Slack node, and everything
else in the send branch silently never ran. This would have broken the
happy path on every single day in production, not just an edge case.

Fixed with alwaysOutputData: true on that node (forces at least one
output item even with 0 matches; the resulting empty {} item doesn't
false-positive in alreadySentSuccessfully since none of its fields match
a real record's shape). Re-ran the validation execution after the fix:
Code - Guard de Envio Slack now runs correctly (shouldSend: false,
reason: 'send_disabled' with SEND_ENABLED=false as expected), and the
IF node's true branch (Slack - Enviar Mensagem) is confirmed to never
fire while disabled.

Added a regression test asserting alwaysOutputData on this node.

157/157 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:56:02 -03:00
GustavoandClaude Sonnet 5 d64b1132b9 feat(v2): wire Slack send branch + Schedule Trigger into the template, generalize transitive-dep test
n8n/workflow.template.json:
- Added the 10 new nodes for the real Slack send branch (Data Table -
  Buscar Envios Recentes, Code - Guard de Envio Slack, IF - Deve Enviar
  Slack?, Slack - Enviar Mensagem, Code - Pular Envio Slack, Code -
  Registrar Envio Sucesso/Falha, the two Data Table - Registrar Envio
  Digest nodes, Code - Lancar Erro Envio Slack) and wired every
  connection -- Code - Montar Digest Coordenacao now fans out to both
  the existing shadow-diff branch (unchanged, always runs) and the new
  send branch in parallel, so a Slack failure never loses the shadow
  audit trail.
- Renamed "Schedule Trigger - Dias uteis 08h (desligado em DEV)" ->
  "Schedule Trigger - Dias uteis 08h": it was already wired to Config
  and already had the correct cron (0 8 * * 1-5) from earlier in the
  project -- this cycle didn't need to build it, just stop calling it
  "desligado" now that it's genuinely production-track (activation is
  controlled by the workflow's own active flag, not the node's name).
- Added settings.timezone: "America/Sao_Paulo" explicitly (previously
  implicit/unset) -- matches the timezone already hardcoded into
  buildTopMessage's dateStr computation elsewhere in this codebase, not
  a new assumption.
- Config node: AI_MAX_CANDIDATES 30->60, added ENVIRONMENT/SEND_ENABLED.

tools/build-workflow.test.js: 10 new tests (connections integrity after
a node rename -- would have caught a real bug this cycle where renaming
the Schedule Trigger orphaned its connection key; Schedule Trigger wired
+ correct cron/timezone; Manual Trigger still present; real Slack node
exists with the right credential/onError; guard runs before Slack;
per-ticket envio registration only fires after the success Data Table
write; Slack failure always registers before throwing; AI_MAX_CANDIDATES
== CANDIDATE_POOL_SIZE; SEND_ENABLED/ENVIRONMENT present; GLPI SQL/query
scan for INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE; dataTable node
operation values are valid enum members -- caught a real bug, "getRows"
[the MCP tool's action name] isn't a valid node parameter value, it's
"get"). Replaced the narrow normalize.js-specific transitive-dependency
test with a general one that scans every logic/*.js top-level function
and asserts any node calling it also has it inlined -- this caught a
second real instance of the same bug class (slack.js's use of hash.js's
djb2Hash, missing from 3 new node headers) before it reached a live run.

n8n/deployed/coordenador-glpi-v2.n8n.json regenerated (43 nodes).

156/156 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:51:15 -03:00
GustavoandClaude Sonnet 5 abc690ddb7 feat(v2): real Slack send pipeline (guard, idempotency, success/failure registration)
Implements the actual send branch: Selecionar Top N -> Montar Mensagem ->
Guard de Envio -> Slack real -> registrar resultado (only after
confirmed success/failure) -- previously simulated/absent.

logic/slack.js (new): shouldSendByConfig (SEND_ENABLED gate, defaults
false), computeMessageHash (djb2Hash of the exact message text, reused
from logic/hash.js), alreadySentSuccessfully (idempotency check: only a
prior status='success' record for the same report_date+run_type+
message_hash blocks a resend -- a prior 'failed' record does not, so
retries after a real failure aren't stuck), evaluateSendGuard (single
auditable decision combining both), buildEnvioDigestRecord.
logic/slack.test.js: 13 tests covering all of the above, explicitly
labeled against the mission's required tests #13-#16.

New node drivers:
- guard-envio-slack.node.js: reads the digest + recent envio records
  (fetched by report_date), computes the send decision.
- Slack node itself (n8n-nodes-base.slack, native, not a raw webhook):
  reuses the real "Slack account" credential (slackApi) and
  $vars.SLACK_CHANNEL_TECNICO channel variable already used by this
  org's own "Notificacao de Erros Globais" workflow on the same n8n
  instance -- V1's own Slack webhook URL was never actually deployed
  (its Config value is literally "SEU/WEBHOOK/AQUI", same class of
  placeholder as the old GLPI URL from the prior cycle), so this reuses
  the one REAL, credentialed Slack mechanism that exists in this
  environment rather than inventing a new destination. onError:
  continueErrorOutput splits success/failure into two branches.
- registrar-envio-sucesso.node.js / registrar-envio-falha.node.js: build
  the success/failure record; only the success path continues to the
  existing per-ticket send-counter logic (rewired below).
- lancar-erro-envio.node.js: after the failure record is written,
  explicitly throws so the n8n execution shows status=error (never
  silently swallow a send failure).
- pular-envio.node.js: terminal no-op for the "should not send" branch
  (SEND_ENABLED=false or already sent today), for observability only.

n8n/nodes/prepare-envio-record.node.js: rewired to read the Top10
selection by name ($('Code - Selecionar Top 10').all()) instead of
$input, since it now runs downstream of the Slack-success registration,
not directly off Selecionar Top10 -- registering per-ticket send counts
before Slack ever confirmed anything was the exact "don't register
before confirmation" bug the mission called out.

logic/message.test.js: added a full-Top10 message-length regression test
(mission requirement: message stays short, no leaked JSON/score/evidence).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:50:54 -03:00
GustavoandClaude Sonnet 5 e0f4b9a5d0 fix(v2): WAITING_*/blocked classes never enter Top10 regardless of contextChanged
Real bug confirmed in the prior cycle's Run A: WAITING_VENDOR tickets
entered the Top10 solely because contextChanged=true (a fresh AI call
happened this run) -- contextChanged should only decide whether to
reanalyze (logic/cache.js), never whether an already-still-blocked
ticket can compete.

logic/cooldown.js: removed the `&& !contextChanged` condition from the
BLOCKING_CLASSIFICATIONS check in computeCooldown -- a ticket whose
CURRENT classification (fresh or cached) is in BLOCKING_CLASSIFICATIONS
is now excluded unconditionally. Also expanded BLOCKING_CLASSIFICATIONS
to the full list required this cycle: WAITING_REQUESTER, WAITING_VENDOR,
WAITING_APPROVAL, SCHEDULED, DEPENDENCY_BLOCKED, PROJECT_LONG_TERM,
DUPLICATE_OR_OBSOLETE (WAITING_APPROVAL/PROJECT_LONG_TERM/
DUPLICATE_OR_OBSOLETE previously only had a score penalty, not exclusion
-- WAITING_APPROVAL has positive base score points, so it could
genuinely win a Top10 slot before this fix). The critical-override escape
hatch (SLA vencido + prioridade alta, etc.) is untouched -- still the
only way a blocked classification can surface.

logic/cooldown.test.js: rewrote the one test that was actually asserting
the buggy behavior ("mudanca de contexto real desbloqueia mesmo em
estado de espera" -> now proves the opposite, with a comment explaining
why); added tests for the chronology-driven unblock case (ticket moves
out of a blocking classification entirely) and the two newly-blocking
classes.
logic/score.test.js: added end-to-end tests through computeScore()
proving WAITING_VENDOR and WAITING_APPROVAL are excludedFromTop10 even
with contextChanged=true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:50:12 -03:00
GustavoandClaude Sonnet 5 482b7ef48e fix(v2): redefine AI actionability semantics + version the classifier (AI_CLASSIFIER_VERSION)
Production-readiness cycle. Classifier was excessively conservative (real
Run A: ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE both 0) and made concrete
semantic mistakes on real tickets:
- #34419: technician "Rafael Brito" (internal) classified as WAITING_VENDOR
  waiting_for -- an internal technician is not a vendor.
- #37389: ticket had concrete open tasks ("Realizar um inventário das
  máquinas", "Alinhar o dia...") but was classified INSUFFICIENT_CONTEXT.
- Generic next_action ("Revisar chamado") repeated across unrelated
  tickets, proving nothing about real actionability.

Rewrote the system prompt (n8n/nodes/prepare-ai-prompt.node.js) with:
practical actionability definition ("suporte consegue fazer algo util
agora", explicit inclusion list); contatar-vs-aguardar distinction with
examples; WAITING_* requires positive evidence (action already taken AND
external dependency still open); technician-vs-vendor distinction citing
the TECNICO context field; STATUS Planejado alone != blocked/scheduled;
SCHEDULED requires an explicit date/window; mandatory chronological
interpretation (most recent apontamento wins over a superseded old
blocker); next_action must be concrete, with explicit accepted/rejected
examples; INSUFFICIENT_CONTEXT bar raised (ask "is there a diagnostic
step available even without full info" first).
n8n/nodes/prepare-ai-prompt.node.test.js: 10 new tests asserting each
rule's text is still present in the prompt (deterministic proxy --
behavioral proof comes from the real DEV run, inspected manually).

logic/classification.js: added AI_CLASSIFIER_VERSION=3 (stamped on every
valid classification); added a deterministic safety net (not just a
prompt hope) -- isWaitingForInternalTechnician() rejects WAITING_VENDOR
when waiting_for matches the ticket's own TECNICO field, routing to a new
fallback_reason 'ai_semantic_conflict' instead of accepting a known-wrong
semantic classification; added next_action genericness rejection
(isGenericNextAction) for ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE only.
n8n/nodes/validate-ai-response.node.js: now passes the ticket into
validateAiClassification() so the technician guard has the facts it needs.

logic/cache.js: added buildCacheKey(contextHash, classifierVersion) ->
"<hash>:v<version>", stored in the same last_context_hash column (no
schema migration) -- bumping AI_CLASSIFIER_VERSION now forces a cache
miss on every ticket without manually clearing the Data Table.
n8n/nodes/decide-needs-ai.node.js / prepare-memoria-analise-record.node.js
updated to compute/persist the composite key.

logic/config.js: AI_MAX_CANDIDATES default raised 30->60 (now equals
CANDIDATE_POOL_SIZE) -- no candidate should go unanalyzed by an
artificial cap now that Run B already proved the cache makes repeat runs
cheap; AI_FALLBACK stays reserved for real provider failures. Also added
SEND_ENABLED (default false) and ENVIRONMENT config fields for the real
Slack send gate (used by logic/slack.js, next commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:49:54 -03:00
GustavoandClaude Sonnet 5 4534099b50 docs: add corrective-cycle final report (Run A/Run B, real execution data)
Live DEV sync completed (all 11 changed nodes pushed, including the
transitive-dependency fix), then two real executions against production
GLPI in shadow mode:

- Run A (execution 14259): 45 candidates, 30 real AI calls (0 errors),
  15 correctly routed to AI_FALLBACK instead of fake ACTIONABLE_NOW,
  3 critical overrides. ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE still 0 --
  reported honestly, not forced.
- Run B (execution 14263, ~1 min later, no data changes): 0 AI calls,
  45/45 cache hits, cached AI_FALLBACK items correctly stayed fallback
  (proves the cache is_fallback bug fix), Top10 correctly shrank
  10->5 as WAITING_VENDOR/WAITING_REQUESTER items hit cooldown.

Full reincident-ticket table (7 tickets) with real evidence, real
before/after HTML-normalization examples (tickets #37389, #36530),
shadow diff vs legacy, webhook-debug-removed confirmation, production-
untouched confirmation. Verdict: CONTINUAR SHADOW MODE.

Workflow y6JFEnp7Z2IJEUe2 ends this cycle at active:false, 33 nodes,
temp webhook removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:09:52 -03:00
GustavoandClaude Sonnet 5 edecd271b9 fix(v2): fix missing transitive dependency (normalize.js) in 3 node drivers
Found while syncing this cycle's fixes to the live DEV workflow and
preparing Run A: tools/build-workflow.js's readLogicSource() only inlines
whatever a node's own `@requires` header lists -- it does not resolve
transitive dependencies. context.js started doing `require('./normalize')`
earlier this cycle, but the three node drivers that need normalizeTicket/
buildAiContextText (which call normalizeGlpiText internally) still only
declared `@requires context.js`, never `normalize.js` itself.

The result compiles fine (new Function(...) syntax-checks pass -- a
ReferenceError is a runtime error, not a syntax error) but would throw
"normalizeGlpiText is not defined" the moment any of these three nodes
actually ran:
- Code - Mesclar Contexto e Calcular Hash (merge-context-hash.node.js)
- Code - Normalizar Candidatos Base (normalize-candidates.node.js)
- Code - Preparar Prompt IA (prepare-ai-prompt.node.js)

This never surfaced in Run 1 (prior session) because normalize.js/the
descricao field didn't exist yet at that point, and it didn't surface in
this cycle's local test suite because node--test only exercises the pure
logic/*.js modules directly (with real requires), never the inlined,
concatenated jsCode a node actually runs in n8n.

Fixed by adding normalize.js explicitly to each of the three headers,
matching the codebase's existing convention for multi-file deps (e.g.
compute-score.node.js already lists `@requires cooldown.js score.js`
together instead of relying on any transitive resolution).

Added a regression test that scans every generated Code node for calls to
normalizeGlpiText/normalizeTicket/buildAiContextText and asserts the
normalizeGlpiText function definition is actually present in that same
inlined source -- would have caught this before it ever reached DEV.

102/102 tests passing (was 101/101 -- +1 new regression test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:01:14 -03:00
GustavoandClaude Sonnet 5 3c87ab55e1 fix(v2): fix cache is_fallback bug, wire observability + data quality, drop debug webhook, fix GLPI URL
- Real bug found by code review while implementing this cycle: "Code -
  Reusar Classificacao em Cache" always hardcoded is_fallback: false when
  reconstructing a classification from memory, even when the cached
  classification was itself AI_FALLBACK. On a cache hit for a previously
  fallback-classified ticket, this would have let it skip
  applyFallbackPolicy's exclusion entirely (the policy checks
  aiClassification.is_fallback first). Fixed to derive is_fallback from
  the cached ai_classification value.
- select-top10.node.js now carries `resolucao` (ia | cache | fallback_cap)
  through to the scored item, previously dropped -- needed to tell cache
  hits apart from real AI calls in observability.
- Code - Comparar Legacy x V2 now computes the full observability summary
  (candidate_count, cache_hits/misses, ai_requested/success/errors,
  ai_fallback_count and by-reason breakdown, classification_counts,
  insufficient_context_count, actionable_real_count,
  critical_override_count, top10_count, blocked_count,
  coordination_count) and the data-quality review (buildDataQualityReview
  over INSUFFICIENT_CONTEXT tickets), both stored in the shadow_runs
  `summary` JSON column -- no new Data Table needed.
- Removed the debug webhook entirely (Debug - Ver Painel, Data Table -
  Buscar Shadow Runs Recentes debug branch, Code - Montar Painel Debug,
  Debug - Responder) and the now-unused debug-panel.node.js driver.
  Workflow went from 37 to 33 nodes. Debugging goes through n8n execution
  data, the Data Tables, and the shadow diff instead, as requested.
- Fixed glpi_base_url: was still V1's own placeholder
  (https://glpi.suaempresa.com.br, never a real value, not report
  anonymization) -- confirmed the real domain (https://glpi.gtigrupo.com.br)
  from the live, active GLPI x WhatsApp workflow's own GLPI API config in
  the same n8n instance. SQL still only ever receives it through
  {GLPI_BASE_URL} substitution from the Config node, never hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 19:50:48 -03:00
GustavoandClaude Sonnet 5 485234dc88 feat(v2): add data-quality audit queue for INSUFFICIENT_CONTEXT, require non-empty next_action
buildDataQualityReview extracts an auditable record per INSUFFICIENT_CONTEXT
ticket (ticket_id, title, ai_reason, context_size, has_description,
followup_count, task_count, has_solution, last_interaction_at) -- not sent
anywhere, just for calibration. Also tightened validateAiClassification to
reject an empty/whitespace-only next_action (was previously accepted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:52:22 -03:00
GustavoandClaude Sonnet 5 34f61abc3b fix(v2): centralize AI_FALLBACK/INSUFFICIENT_CONTEXT eligibility policy in cooldown.js
applyFallbackPolicy (new, in logic/cooldown.js alongside the isCriticalOverride
it reuses) is now the single place that decides whether an AI_FALLBACK or
INSUFFICIENT_CONTEXT ticket can enter the Top10: blocked by default, let
through only via an explicit critical override (SLA vencido + prioridade/
urgencia alta, OR sem_tecnico + prioridade/urgencia alta -- isCriticalOverride
extended to cover both, previously only covered SLA+priority). score.js
calls this policy and sets excludedFromTop10/exclusionReason/decisionReason
accordingly, with an auditable *_critical_override or *_no_override reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:51:17 -03:00
GustavoandClaude Sonnet 5 00a42db269 fix(v2): AI_FALLBACK replaces the ACTIONABLE_NOW-with-confidence-0 lie, normalize null-like strings
Fallback (ai_disabled, ai_error, invalid_json, ai_max_candidates_cap --
any candidate the AI never actually analyzed) now produces classification
'AI_FALLBACK', an explicit technical state that is never part of the
11-class enum sent to OpenAI (the AI itself can never legitimately return
it). Previously it silently claimed ACTIONABLE_NOW with confidence 0,
which made unanalyzed tickets indistinguishable from genuinely confirmed
ones at the classification-label level.

Also normalizes blocker_type/waiting_for when the model returns the
literal string "null"/"none"/"n/a"/"" instead of JSON null -- done
once, in the validation layer (logic/classification.js), not scattered
around the workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:49:22 -03:00
GustavoandClaude Sonnet 5 db7a261a10 feat(v2): normalize GLPI text before context/hash, add real ticket description
context.normalizeTicket now runs normalizeGlpiText on titulo, descricao
(new field, sourced from glpi_tickets.content -- never fetched before),
and every followup/task texto. buildAiContextText restructured into
delimited sections (TITULO / DESCRICAO ORIGINAL / STATUS-TECNICO-GRUPO /
FOLLOW-UPS / TAREFAS) instead of one flat string.

hash.buildContextFingerprint now includes titulo/descricao and the
normalized followup/task texto (previously only id/date/status), so:
(a) the same content encoded differently (HTML vs escaped entities)
produces the same hash, and (b) editing a followup's actual content
(same id/date) is now detected as a real context change -- previously
silently invisible to the hash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:47:32 -03:00
GustavoandClaude Sonnet 5 d7650308a9 feat(v2): add robust GLPI text normalization (HTML/entities, multi-level escaping)
Handles: numeric entities (&#60; &#62;), named entities (&amp; &nbsp; etc),
HTML tags, multi-level escaping (up to 5 decode iterations, capped to
avoid infinite loop), whitespace/newline collapsing. Preserves dates,
names, numbers, error codes, hostnames, versions -- only strips markup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:45:36 -03:00
GustavoandClaude Sonnet 5 6bbdaa5fe8 docs: add final mission report (real execution data, no fabricated numbers)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:33:04 -03:00
GustavoandClaude Sonnet 5 2881fb52de docs: update schema introspection note with confirmed findings from real run
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:31:22 -03:00
GustavoandClaude Sonnet 5 ea105d525c fix(v2): three real bugs found by an actual end-to-end run against production GLPI data
Ran the deployed workflow for real (temporary webhook trigger, briefly
activated, immediately deactivated and removed after each attempt) against
the real GLPI MySQL database and a real OpenAI credential. Found and fixed:

1. $env access is blocked on this n8n instance (N8N_BLOCK_ENV_ACCESS_IN_NODE).
   parseConfig($env) threw "access to env vars denied" on the very first
   Code node. Config now comes from the "Config - Coordenador GLPI v2" Set
   node instead (same pattern the V1 workflow already used) -- expanded
   that node with all 13 tunable parameters, and pointed the 3 affected
   node drivers (build-sql-candidatos, decide-needs-ai, select-top10) at
   it instead of $env.

2. Data Table "upsert" operation requires both `columns` (values) and a
   separate `filters` (match condition) -- I had only set columns with
   matchingColumns, which isn't enough; n8n refused to activate the
   workflow ("Missing or invalid required parameters: filters"). Added
   filters.conditions matching on ticket_id to both memory-writing Data
   Table nodes.

3. Real architecture bug: "Data Table - Buscar Memoria" sat inline in the
   41-candidate chain (Mesclar Contexto -> Buscar Memoria -> Decidir
   Necessidade de IA). A bulk "get all rows" Data Table node run with 41
   input items produced 41 identical fetches and its OWN output (the empty
   memory table on a first run) replaced the 41 candidates entirely --
   "Decidir Necessidade de IA" received 0 items and the whole main branch
   silently skipped. Moved the memory fetch to a parallel branch off
   Config (fires once) and added a real Merge node ("Merge - Aguardar
   Memoria") to guarantee both the candidates and the memory rows have
   executed before "Decidir Necessidade de IA" runs -- same synchronization
   pattern already used for context and for the shadow comparison.

Also fixed a cosmetic bug found in the real message output: buildTopMessage
produced a quadruple newline between the header and the first ticket block
([header, '', ...blocks].join('\n\n') double-counts the blank line already
inserted by the '\n\n' join). Added a regression test.

Verified with a real execution (id 13599, 24/08/2026 19:25 UTC) against the
live GLPI database: 41 real candidates, 30 sent to OpenAI (capped), 11
fallback-capped, memory table populated with 41 real rows including
genuine WAITING_VENDOR/WAITING_REQUESTER classifications with real
blocker_type/waiting_for fields extracted from real ticket task content,
and a real shadow_runs row comparing Legacy x V2 (4 tickets removed by V2:
2 for INSUFFICIENT_CONTEXT, 1 WAITING_REQUESTER, 1 WAITING_VENDOR; 4 added).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 16:30:13 -03:00
GustavoandClaude Sonnet 5 af48ea69d8 feat(v2): create DEV data tables, deploy V2 workflow (inactive), record schema introspection
- Fixed 3 remaining "return [...].join(...)" false-positive triggers in
  logic/message.js (n8n-mcp's static validator flags any inner return-array
  pattern as if it were the node's own output shape) by extracting each to
  a named variable before joining. All 3 occurrences of the pattern lived
  inside buildTopMessage/buildCoordinationDigest, which get fully inlined
  into both "Code - Montar Mensagem Curta" and "Code - Montar Digest
  Coordenacao" (whichever function is actually called, the whole module is
  inlined), so both nodes needed the same fix.
- Deployed [DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)
  (id y6JFEnp7Z2IJEUe2) to devn8n.gtigrupo.com.br: 36 nodes, inactive,
  n8n_validate_workflow reports valid:true (0 errors, 1 minor deprecation
  warning on continueOnFail).
- Ran n8n_autofix_workflow: 6 safe typeVersion upgrades applied and mirrored
  back into n8n/workflow.template.json so future rebuilds stay in sync.
- Found and fixed a real safety issue: creating the workflow before the
  "MySQL GLPI - Coordenador" credential existed caused n8n to silently
  auto-bind the 3 MySQL nodes to the only other mySql-type credential in
  the instance (an unrelated Asaas billing credential). Cleared those
  credential bindings (both live and in the template) rather than leave
  them pointing at the wrong database.
- Created the two Data Tables the pipeline needs: coordenador_glpi_memoria
  (17 columns) and coordenador_glpi_shadow_runs (6 columns).
- Documented the pending schema introspection (glpi_tickettasks.state
  column name unverified, blocked on the MySQL credential) and an unrelated
  discovery (two other Data Tables, dev_glpi_v2_insights_memory and
  dev_glpi_v2_run_history, appeared on the DEV instance mid-session from a
  source other than this one) in docs/superpowers/notes/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:47:48 -03:00
GustavoandClaude Sonnet 5 a99a25cb1a feat(v2): complete n8n workflow template (36 nodes) covering full V2 pipeline + shadow branch
- 14 new Code node drivers implementing the pipeline stages: normalize
  candidates, build context SQL, merge context + hash, decide AI need,
  prepare AI prompt, validate AI response, AI-disabled fallback, reuse
  cached classification, compute score, select top 10, prepare memory/
  envio records, build message/digest, compare legacy x V2, debug panel.
- Consolidated the two context queries (followups/tasks) into a single
  UNION ALL query tagged by tipo, and added a "Merge - Unir Shadow"
  synchronization node before comparing legacy x V2: n8n does not
  guarantee relative execution order between sibling branches that only
  share an early common ancestor, so cross-branch data was only safe to
  read by name once a real Merge node forced both to complete first.
- Reordered score calculation before the memory upsert (score is needed
  in the persisted row) and turned both Data Table writes into
  side-effect branches that fan out from the main path instead of
  gating it, so the Data Table node's own output shape never needs to
  carry the full pipeline state forward.
- Fixed a duplicate-identifier bug in the build script: score.js's
  require('./cooldown') survived inlining and collided with
  cooldown.js's own computeCooldown function in the same Code node
  scope. build-workflow.js now strips inter-module requires when
  inlining, and a new test syntax-checks every generated jsCode string
  plus asserts no leftover require() calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:31:44 -03:00
GustavoandClaude Sonnet 5 ec596a29a8 feat(v2): add build script that inlines tested logic/*.js into n8n Code nodes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:20:54 -03:00
GustavoandClaude Sonnet 5 00bb89f4f4 feat(v2): candidate pool SQL (admin exclusion preserved, no hard LIMIT 10) + verbatim legacy SQL for shadow mode
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:19:39 -03:00
GustavoandClaude Sonnet 5 60015c0972 feat(v2): add short message and coordination digest builders
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:18:15 -03:00
GustavoandClaude Sonnet 5 d3917707e0 feat(v2): add Top10 selection with soft group diversity, hard 10-item cap
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:17:37 -03:00
GustavoandClaude Sonnet 5 749be7dbae feat(v2): add auditable final score combining actionability, baseline and cooldown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:17:06 -03:00
GustavoandClaude Sonnet 5 014c48d578 feat(v2): add state-based cooldown with critical override
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:16:25 -03:00
GustavoandClaude Sonnet 5 efba7792fa feat(v2): add safe AI fallback and cache/reanalysis decision
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:15:50 -03:00
GustavoandClaude Sonnet 5 c445e00fc5 feat(v2): add strict schema validation for AI classification JSON
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:15:21 -03:00
GustavoandClaude Sonnet 5 7dccb85efa feat(v2): add ticket normalization and bounded AI context builder
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:14:50 -03:00
GustavoandClaude Sonnet 5 30e532f811 feat(v2): add context fingerprint hash that ignores time-only fields
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:14:17 -03:00
GustavoandClaude Sonnet 5 32ebe0d4f2 feat(v2): add parseConfig with clamped TOP_N and safe defaults
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:48 -03:00
GustavoandClaude Sonnet 5 ff5620c95c chore: scaffold node:test runner for V2 deterministic logic
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:19 -03:00
GustavoandClaude Sonnet 5 49f487d636 docs: add V2 implementation plan (17 tasks, TDD for deterministic logic)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:12:08 -03:00
GustavoandClaude Sonnet 5 de147107d5 docs: add V2 design spec (approved)
docs/superpowers/specs/2026-08-24-coordenador-glpi-v2-design.md
Covers Etapa 0 recovery findings, architecture, data model, AI schema,
scoring, message format, shadow mode. Approved by user in chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:00:34 -03:00
GustavoandClaude Sonnet 5 1eb1f80b4f chore: baseline import of existing Coordenador GLPI V1 project
Snapshot of the pre-existing, working V1 automation (n8n workflow +
SQL query + docker-compose + README) exactly as found on disk, before
any V2 changes. .env is intentionally not tracked (gitignored,
contains local secrets).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:00:26 -03:00
111 changed files with 9966 additions and 899 deletions
+11
View File
@@ -1 +1,12 @@
.env
.env.*
!.env.example
node_modules/
*.log
logs/
*.sqlite
*.db
output/
tmp/
.DS_Store
Thumbs.db
+29 -238
View File
@@ -1,248 +1,39 @@
# Resumo Diário GLPI - MVP
# SLACK_COORD_RELATORIO
Workflow N8N inicial para gerar um resumo diário de chamados usando dados simulados.
Monorepo com dois componentes relacionados de automação GLPI/Slack, ambos
implementados como workflows n8n com lógica em Node.js testável:
## Subir o N8N com Docker
| Diretório | Componente | Função |
|---|---|---|
| `coordenador/` | Coordenador GLPI | Analisa os chamados abertos no GLPI em dias úteis, classifica cada um com IA e envia no Slack os 10 que precisam de ação humana. |
| `relatorio/` | Resumo/Relatório Operacional GLPI | Gera e envia ao Slack o resumo diário/semanal de chamados, com números determinísticos (SQL) e insights opcionais de IA. |
1. Copie `.env.example` para `.env`.
2. Edite `.env` e troque `N8N_BASIC_AUTH_PASSWORD` e `N8N_ENCRYPTION_KEY`.
3. Suba o container:
Cada diretório tem README, testes e documentação próprios; comece por eles.
```powershell
docker compose up -d
## Relação entre os componentes
- São projetos independentes em código: não há imports, arquivos ou paths
compartilhados entre `coordenador/` e `relatorio/`.
- Compartilham infraestrutura/runtime: a mesma instância n8n, o acesso
somente leitura ao MySQL do GLPI e o mesmo bot/canal Slack.
- Cada um mantém suas próprias Data Tables no n8n.
## Testes
```bash
cd coordenador && node --test
cd relatorio && node --test test/*.test.js
```
4. Acesse:
Os scripts de build (`tools/build*.js`) regravam os workflows gerados; leia o
README do componente antes de executá-los.
```text
http://localhost:5678
```
## Histórico
5. Para parar:
Os históricos Git originais dos dois projetos foram preservados nesta
consolidação (`git subtree add` sem squash).
```powershell
docker compose down
```
## Segredos
## Arquivo
- `Resumo Diario GLPI - MVP.n8n.json`
- `Resumo Diario GLPI - Real Slack.n8n.json`
- `Resumo Diario GLPI - SQL Slack.n8n.json`
- `Resumo Semanal GLPI - SQL Slack.n8n.json`
- `sql/indicadores_diario_glpi.sql`
- `sql/indicadores_semana_glpi.sql`
## Workflow Recomendado
Use este workflow para o relatório automatico correto:
- `Resumo Diario GLPI - SQL Slack.n8n.json`
Essa versão roda de segunda a sexta as 08:00 e escolhe o periodo automaticamente:
- terca-feira: envia o relatorio da segunda-feira
- quarta-feira: envia o relatorio da terca-feira
- quinta-feira: envia o relatorio da quarta-feira
- sexta-feira: envia o relatorio da quinta-feira
- segunda-feira: envia o relatorio da semana anterior inteira, de segunda a domingo
Essa versão usa a regra aprendida com a query semanal, aplicada ao periodo selecionado:
- chamados abertos no periodo: `glpi_tickets.date` entre `data_inicio` e `data_fim`
- chamados fechados no periodo: `glpi_tickets.closedate` entre `data_inicio` e `data_fim`
- chamados fechados no periodo podem ter sido abertos em qualquer data anterior
- filtro `is_deleted = false`
- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache`
### Como configurar o SQL automatico
1. Importe `Resumo Diario GLPI - SQL Slack.n8n.json`.
2. Configure a credencial MySQL no node `MySQL - Indicadores diario GLPI`.
3. Configure a credencial de IA/OpenAI no node `Z IA Chat Model`.
4. Abra `Config - Diario SQL` e preencha:
- `data_alvo`: deixe vazio para o modo automatico ou use `2026-07-03` para teste manual de um dia especifico
- `slack_webhook_url`
- `openai_model`, padrão `gpt-5.5`
5. Execute pelo `Manual Trigger` para testar.
6. Ative o workflow para usar o `Schedule Trigger - Dias uteis 08h`.
Se o agente de IA falhar, o workflow envia o resumo fallback sem IA com os números SQL.
### Agente de IA
O workflow usa os nodes nativos de IA do N8N:
- `AI Agent - Coordenador GLPI`: melhora o texto do relatorio antes do Slack
- `Z IA Chat Model`: chat model conectado ao agente pela entrada `Chat Model`
O agente recebe o resumo tecnico gerado pelo SQL e deve apenas melhorar a comunicacao. Ele foi instruido a nao inventar numeros.
## Workflow SQL Recomendado
Para bater exatamente com os indicadores do GLPI, use:
- `Resumo Semanal GLPI - SQL Slack.n8n.json`
Essa versão consulta diretamente o banco MySQL/MariaDB do GLPI e executa a mesma lógica da query de referência:
- abertos pela coluna `glpi_tickets.date`
- fechados pela coluna `glpi_tickets.closedate`
- filtro `is_deleted = false`
- filtro de categorias pelas árvores `7`, `16`, `17` e `18` usando `ancestors_cache`
Isso é diferente da API REST, que pode não trazer todos os chamados necessários para reproduzir o indicador semanal, principalmente porque chamados fechados na semana podem ter sido abertos em semanas anteriores.
### Como configurar
1. Importe `Resumo Semanal GLPI - SQL Slack.n8n.json`.
2. Configure a credencial MySQL no node `MySQL - Indicadores semana GLPI`.
3. Abra `Config - Slack e OpenAI` e preencha:
- `slack_webhook_url`
- `openai_api_key`
- `openai_model`, padrão `gpt-5.5`
4. Execute pelo `Manual Trigger`.
Se a OpenAI falhar, o workflow envia um resumo fallback sem IA com os números da query.
## Como usar
1. No N8N, vá em `Workflows`.
2. Clique em `Import from File`.
3. Selecione `Resumo Diario GLPI - MVP.n8n.json`.
4. Abra o node `OpenAI - Gerar resumo`.
5. Selecione sua credencial da OpenAI.
6. Execute o workflow pelo `Manual Trigger`.
## Fluxo
```text
Manual Trigger
↓
Set - Dados simulados
↓
Code - Calcular indicadores
↓
OpenAI - Gerar resumo
```
## Próxima etapa
Depois que o MVP estiver gerando o texto corretamente, substitua o node `Set - Dados simulados` pelos nodes HTTP Request do GLPI.
## Workflow Real com GLPI e Slack
Importe o arquivo:
- `Resumo Diario GLPI - Real Slack.n8n.json`
Depois da importação, abra o node `Config - GLPI e Slack` e preencha:
- `glpi_url`: URL base do GLPI, exemplo `https://glpi.suaempresa.com.br`
- `glpi_app_token`: App Token do GLPI
- `glpi_user_token`: User Token do usuário de integração
- `slack_webhook_url`: URL do Incoming Webhook do Slack
- `range_tickets`: quantidade buscada na API, exemplo `0-999`
- `page_size`: tamanho de cada pagina buscada no GLPI, padrao `1000`
- `page_count`: quantidade de paginas por ordenacao, padrao `2`
- `data_alvo`: data do relatório. Deixe vazio para usar o dia atual, ou informe uma data como `2026-07-03` ou `03/07/26`
- `openai_api_key`: chave da OpenAI usada pelo node `OpenAI - Gerar resumo`
- `openai_model`: modelo usado na OpenAI, padrão `gpt-5.5`
O node `OpenAI - Gerar resumo` usa `HTTP Request` direto para `https://api.openai.com/v1/chat/completions`.
O workflow busca tickets em paginas e combina duas ordenacoes:
- `id DESC`, para capturar chamados abertos recentemente
- `date_mod DESC`, para capturar chamados antigos que foram modificados, solucionados ou fechados recentemente
Depois ele remove duplicados por `id` e filtra no N8N pela `data_alvo`. O cálculo separa:
- chamados abertos na data, usando `date_creation`, `date` ou `data_abertura`
- chamados fechados na data, usando `solvedate`, `closedate` ou `data_fechamento`
- pendentes, usando o status atual do ticket filtrado
Na base atual, o GLPI informou `1216` tickets. Por isso o padrão recomendado é `page_size=1000` e `page_count=2`, cobrindo `0-1999` por ordenação sem estourar o total. Se aumentar `page_count`, o GLPI pode retornar `ERROR_RANGE_EXCEED_TOTAL` para páginas acima do total existente.
Execute pelo `Manual Trigger` primeiro. Depois que validar, troque por `Schedule Trigger`.
Para gerar o relatório da sexta-feira `03/07/2026`, use no campo `data_alvo`:
```text
2026-07-03
```
### Criar Webhook no Slack
1. Acesse `https://api.slack.com/apps`.
2. Crie ou abra um app do Slack.
3. Ative `Incoming Webhooks`.
4. Crie um webhook para o canal desejado.
5. Copie a URL para o campo `slack_webhook_url`.
### Fluxo Real
```text
Manual Trigger
↓
Config - GLPI e Slack
↓
HTTP - GLPI initSession
↓
HTTP - GLPI buscar tickets
↓
Code - Filtrar chamados de hoje
↓
Code - Calcular indicadores
↓
OpenAI - Gerar resumo
↓
HTTP - Slack enviar mensagem
↓
HTTP - GLPI killSession
```
### Observação
Os campos retornados pelo GLPI podem variar conforme versão e configuração. Se o node `Code - Filtrar chamados de hoje` retornar categoria ou cliente como ID em vez de nome, ajuste o mapeamento depois de ver o retorno real da API.
### Erro Slack `400 - no_text`
Se o node `HTTP - Slack enviar mensagem` retornar `400 - no_text`, o corpo da requisição foi enviado sem o campo `text`.
No node `HTTP - Slack enviar mensagem`, configure:
- `Method`: `POST`
- `Send Body`: ativo
- `Body Content Type`: `JSON`
- `Specify Body`: `Using JSON`
- `JSON Body`:
```json
{
"text": "{{ $json.text }}"
}
```
### Erro OpenAI `too many requests`
Se o node `OpenAI - Gerar resumo` retornar `The service is receiving too many requests from you`, a OpenAI respondeu com limite de uso/rate limit.
Verifique:
- Se a chave `openai_api_key` está correta
- Se a conta OpenAI tem crédito/billing ativo
- Se houve muitas execuções seguidas em pouco tempo
- Se o modelo `gpt-4o-mini` está disponível para a chave usada
O workflow importável já está configurado com retry:
- 3 tentativas
- 30 segundos entre tentativas
- limite de resposta de 700 tokens
Se a OpenAI continuar falhando, o workflow continua a execução e envia ao Slack um resumo técnico gerado pelo node `Code - Preparar mensagem Slack`, sem IA.
Nenhum segredo vive neste repositório. Credenciais ficam no credential store
do n8n e os arquivos `.env` são ignorados (apenas `.env.example` é versionado).
-133
View File
@@ -1,133 +0,0 @@
{
"name": "Resumo Diário GLPI - MVP",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
260,
300
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "chamados-simulados",
"name": "chamados",
"type": "array",
"value": "=[{\"id\":101,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 08:20\",\"data_fechamento\":null},{\"id\":102,\"cliente\":\"Cliente B\",\"categoria\":\"Impressora\",\"status\":\"Fechado\",\"prioridade\":\"Baixa\",\"data_abertura\":\"2026-07-04 09:10\",\"data_fechamento\":\"2026-07-04 10:30\"},{\"id\":103,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Pendente\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 11:00\",\"data_fechamento\":null},{\"id\":104,\"cliente\":\"Cliente C\",\"categoria\":\"Acesso remoto\",\"status\":\"Fechado\",\"prioridade\":\"Média\",\"data_abertura\":\"2026-07-04 13:45\",\"data_fechamento\":\"2026-07-04 15:00\"},{\"id\":105,\"cliente\":\"Cliente A\",\"categoria\":\"VPN\",\"status\":\"Aberto\",\"prioridade\":\"Alta\",\"data_abertura\":\"2026-07-04 16:10\",\"data_fechamento\":null}]"
}
]
},
"options": {}
},
"id": "set-dados-simulados",
"name": "Set - Dados simulados",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
500,
300
]
},
{
"parameters": {
"jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusAbertosOuPendentes = ['Aberto', 'Novo', 'Processando', 'Pendente'];\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst totalChamados = chamados.length;\n\nconst abertosOuPendentes = chamados.filter(c =>\n statusAbertosOuPendentes.includes(c.status)\n).length;\n\nconst fechados = chamados.filter(c =>\n statusFechados.includes(c.status)\n).length;\n\nconst pendentes = chamados.filter(c =>\n !statusFechados.includes(c.status)\n).length;\n\nconst altaPrioridadePendentes = chamados.filter(c =>\n prioridadesAltas.includes(c.prioridade) &&\n !statusFechados.includes(c.status)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n total_chamados: totalChamados,\n chamados_abertos_ou_pendentes: abertosOuPendentes,\n chamados_fechados: fechados,\n chamados_pendentes: pendentes,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];"
},
"id": "code-calcular-indicadores",
"name": "Code - Calcular indicadores",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
760,
300
]
},
{
"parameters": {
"resource": "chat",
"operation": "message",
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender."
},
{
"role": "user",
"content": "Estrutura obrigatória:\n\n1. Resumo geral do dia\n2. Chamados abertos, fechados e pendentes\n3. Categorias mais recorrentes\n4. Clientes com maior volume\n5. Chamados de maior atenção\n6. Recomendações para o próximo dia\n\nDados do dia:\n\n{{ JSON.stringify($json) }}\n\nO resultado deve ser algo pronto para enviar no grupo, e-mail ou relatório."
}
]
},
"options": {
"temperature": 0.2
}
},
"id": "openai-gerar-resumo",
"name": "OpenAI - Gerar resumo",
"type": "n8n-nodes-base.openAi",
"typeVersion": 1,
"position": [
1020,
300
],
"credentials": {
"openAiApi": {
"id": "",
"name": "OpenAI account"
}
}
}
],
"pinData": {},
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Set - Dados simulados",
"type": "main",
"index": 0
}
]
]
},
"Set - Dados simulados": {
"main": [
[
{
"node": "Code - Calcular indicadores",
"type": "main",
"index": 0
}
]
]
},
"Code - Calcular indicadores": {
"main": [
[
{
"node": "OpenAI - Gerar resumo",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}
-444
View File
@@ -1,444 +0,0 @@
{
"name": "Resumo Diário GLPI - Real Slack",
"nodes": [
{
"parameters": {},
"id": "manual-trigger-real",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
180,
320
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "glpi-url",
"name": "glpi_url",
"type": "string",
"value": "https://SEU-GLPI-AQUI"
},
{
"id": "glpi-app-token",
"name": "glpi_app_token",
"type": "string",
"value": "COLE_SEU_APP_TOKEN_AQUI"
},
{
"id": "glpi-user-token",
"name": "glpi_user_token",
"type": "string",
"value": "COLE_SEU_USER_TOKEN_AQUI"
},
{
"id": "slack-webhook-url",
"name": "slack_webhook_url",
"type": "string",
"value": "https://hooks.slack.com/services/SEU/WEBHOOK/AQUI"
},
{
"id": "range-tickets",
"name": "range_tickets",
"type": "string",
"value": "0-999"
},
{
"id": "data-alvo",
"name": "data_alvo",
"type": "string",
"value": ""
},
{
"id": "openai-api-key",
"name": "openai_api_key",
"type": "string",
"value": "COLE_SUA_OPENAI_API_KEY_AQUI"
},
{
"id": "openai-model",
"name": "openai_model",
"type": "string",
"value": "gpt-5.5"
},
{
"id": "page-size",
"name": "page_size",
"type": "number",
"value": 1000
},
{
"id": "page-count",
"name": "page_count",
"type": "number",
"value": 2
}
]
},
"options": {}
},
"id": "config-glpi-slack",
"name": "Config - GLPI e Slack",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
420,
320
]
},
{
"parameters": {
"url": "={{ $json.glpi_url.replace(/\\/$/, '') + '/apirest.php/initSession' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "App-Token",
"value": "={{ $json.glpi_app_token }}"
},
{
"name": "Authorization",
"value": "={{ 'user_token ' + $json.glpi_user_token }}"
}
]
},
"options": {}
},
"id": "http-init-session",
"name": "HTTP - GLPI initSession",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
320
]
},
{
"parameters": {
"jsCode": "const config = $('Config - GLPI e Slack').first().json;\n\nconst pageSize = Number(config.page_size || 1000);\nconst pageCount = Number(config.page_count || 2);\nconst sorts = ['id', 'date_mod'];\nconst items = [];\n\nfor (const sort of sorts) {\n for (let page = 0; page < pageCount; page++) {\n const start = page * pageSize;\n const end = start + pageSize - 1;\n\n items.push({\n json: {\n ...config,\n sort_field: sort,\n range_tickets: `${start}-${end}`\n }\n });\n }\n}\n\nreturn items;"
},
"id": "code-gerar-paginas-glpi",
"name": "Code - Gerar paginas GLPI",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
820,
320
]
},
{
"parameters": {
"url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/Ticket/' }}",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "range",
"value": "={{ $json.range_tickets }}"
},
{
"name": "sort",
"value": "={{ $json.sort_field }}"
},
{
"name": "order",
"value": "DESC"
},
{
"name": "expand_dropdowns",
"value": "true"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Session-Token",
"value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}"
},
{
"name": "App-Token",
"value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}"
}
]
},
"options": {}
},
"id": "http-buscar-tickets",
"name": "HTTP - GLPI buscar tickets",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1060,
320
],
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\n\nconst statusMap = {\n 1: 'Novo',\n 2: 'Processando',\n 3: 'Planejado',\n 4: 'Pendente',\n 5: 'Solucionado',\n 6: 'Fechado'\n};\n\nconst prioridadeMap = {\n 1: 'Muito baixa',\n 2: 'Baixa',\n 3: 'Média',\n 4: 'Alta',\n 5: 'Muito alta',\n 6: 'Crítica'\n};\n\nfunction coletarTickets(items) {\n const porId = new Map();\n let totalRecebido = 0;\n\n for (const item of items) {\n const data = item.json;\n let lista = [];\n\n if (Array.isArray(data)) {\n lista = data;\n } else if (Array.isArray(data.data)) {\n lista = data.data;\n } else if (data.id) {\n lista = [data];\n }\n\n totalRecebido += lista.length;\n\n for (const ticket of lista) {\n if (ticket.id === undefined || ticket.id === null) continue;\n porId.set(String(ticket.id), ticket);\n }\n }\n\n return {\n tickets: [...porId.values()],\n totalRecebido,\n totalUnico: porId.size\n };\n}\n\nfunction limparTexto(valor) {\n return String(valor)\n .replace(/&#62;/g, '>')\n .replace(/&gt;/g, '>')\n .replace(/&#60;/g, '<')\n .replace(/&lt;/g, '<')\n .replace(/&amp;/g, '&')\n .trim();\n}\n\nfunction textoValor(valor, mapa) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n return limparTexto(mapa[valor] || mapa[String(valor)] || valor);\n}\n\nfunction extrairNome(valor) {\n if (valor === null || valor === undefined || valor === '') return 'Não informado';\n if (typeof valor === 'object') return limparTexto(valor.name || valor.completename || valor.id || 'Não informado');\n return limparTexto(valor);\n}\n\nfunction normalizarDataAlvo(valor) {\n const texto = String(valor || '').trim();\n\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(texto)) return texto;\n\n const matchPtBr = texto.match(/^(\\d{2})\\/(\\d{2})\\/(\\d{2}|\\d{4})$/);\n if (matchPtBr) {\n const [, dia, mes, ano] = matchPtBr;\n const anoCompleto = ano.length === 2 ? `20${ano}` : ano;\n return `${anoCompleto}-${mes}-${dia}`;\n }\n\n return new Date().toISOString().slice(0, 10);\n}\n\nconst coleta = coletarTickets(inputItems);\nconst config = $('Config - GLPI e Slack').first().json;\nconst dataAlvo = normalizarDataAlvo(config.data_alvo);\n\nconst chamadosDoPeriodo = coleta.tickets.filter(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataSolucao = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return String(dataAbertura).startsWith(dataAlvo) || String(dataSolucao).startsWith(dataAlvo);\n});\n\nconst chamados = chamadosDoPeriodo.map(ticket => {\n const dataAbertura = ticket.date_creation || ticket.date || ticket.data_abertura || '';\n const dataFechamento = ticket.solvedate || ticket.closedate || ticket.data_fechamento || '';\n\n return {\n id: ticket.id,\n titulo: extrairNome(ticket.name || ticket.titulo || 'Sem título'),\n status: textoValor(ticket.status, statusMap),\n prioridade: textoValor(ticket.priority, prioridadeMap),\n categoria: extrairNome(ticket.itilcategories_id || ticket.categoria),\n cliente: extrairNome(ticket.entities_id || ticket.cliente),\n data_abertura: dataAbertura || null,\n data_fechamento: dataFechamento || null,\n aberto_no_periodo: String(dataAbertura).startsWith(dataAlvo),\n fechado_no_periodo: String(dataFechamento).startsWith(dataAlvo)\n };\n});\n\nreturn [\n {\n json: {\n data_filtro: dataAlvo,\n paginas_consultadas: inputItems.length,\n total_recebido_glpi: coleta.totalRecebido,\n total_retornado_glpi: coleta.totalUnico,\n chamados\n }\n }\n];"
},
"id": "code-filtrar-hoje",
"name": "Code - Filtrar chamados de hoje",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1300,
320
]
},
{
"parameters": {
"jsCode": "const chamados = $input.first().json.chamados || [];\n\nfunction contarPorCampo(lista, campo) {\n return lista.reduce((acc, item) => {\n const valor = item[campo] || 'Não informado';\n acc[valor] = (acc[valor] || 0) + 1;\n return acc;\n }, {});\n}\n\nfunction ordenarContagem(objeto) {\n return Object.entries(objeto)\n .sort((a, b) => b[1] - a[1])\n .reduce((acc, [chave, valor]) => {\n acc[chave] = valor;\n return acc;\n }, {});\n}\n\nconst statusFechados = ['Fechado', 'Solucionado', 'Encerrado'];\nconst prioridadesAltas = ['Alta', 'Muito alta', 'Crítica'];\n\nconst chamadosAbertosNoPeriodo = chamados.filter(c => c.aberto_no_periodo);\nconst chamadosFechadosNoPeriodo = chamados.filter(c => c.fechado_no_periodo);\nconst chamadosPendentes = chamados.filter(c => !statusFechados.includes(c.status));\n\nconst altaPrioridadePendentes = chamadosPendentes.filter(c =>\n prioridadesAltas.includes(c.prioridade)\n).length;\n\nconst porCategoria = ordenarContagem(contarPorCampo(chamados, 'categoria'));\nconst porCliente = ordenarContagem(contarPorCampo(chamados, 'cliente'));\nconst porPrioridade = ordenarContagem(contarPorCampo(chamados, 'prioridade'));\nconst porStatus = ordenarContagem(contarPorCampo(chamados, 'status'));\n\nreturn [\n {\n json: {\n data_relatorio: new Date().toISOString(),\n data_filtro: $input.first().json.data_filtro,\n paginas_consultadas: $input.first().json.paginas_consultadas,\n total_recebido_glpi: $input.first().json.total_recebido_glpi,\n total_retornado_glpi: $input.first().json.total_retornado_glpi,\n total_chamados: chamados.length,\n chamados_abertos_hoje: chamadosAbertosNoPeriodo.length,\n chamados_fechados_hoje: chamadosFechadosNoPeriodo.length,\n chamados_abertos_ou_pendentes: chamadosPendentes.length,\n chamados_fechados: chamadosFechadosNoPeriodo.length,\n chamados_pendentes: chamadosPendentes.length,\n chamados_alta_prioridade_pendentes: altaPrioridadePendentes,\n chamados_por_categoria: porCategoria,\n chamados_por_cliente: porCliente,\n chamados_por_prioridade: porPrioridade,\n chamados_por_status: porStatus,\n chamados_detalhados: chamados\n }\n }\n];"
},
"id": "code-calcular-real",
"name": "Code - Calcular indicadores",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
320
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $('Config - GLPI e Slack').first().json.openai_api_key }}"
}
]
},
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ { model: $('Config - GLPI e Slack').first().json.openai_model || 'gpt-5.5', temperature: 0.2, max_tokens: 700, messages: [ { role: 'system', content: 'Você é um analista de infraestrutura gerando um resumo diário de suporte para a equipe técnica. Use apenas os dados fornecidos. Não invente números, clientes, categorias ou chamados. Gere um resumo em português do Brasil, objetivo, profissional e fácil de entender para envio no Slack.' }, { role: 'user', content: 'Estrutura obrigatória:\\n\\n1. Resumo geral do dia\\n2. Chamados abertos, fechados e pendentes\\n3. Categorias mais recorrentes\\n4. Clientes com maior volume\\n5. Chamados de maior atenção\\n6. Recomendações para o próximo dia\\n\\nDados do dia:\\n\\n' + JSON.stringify($json) + '\\n\\nO resultado deve estar pronto para enviar em um canal do Slack.' } ] } }}",
"options": {}
},
"id": "openai-gerar-resumo-real",
"name": "OpenAI - Gerar resumo",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1780,
320
],
"continueOnFail": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 30000
},
{
"parameters": {
"jsCode": "const data = $input.first().json;\n\nconst textoIa =\n data.choices?.[0]?.message?.content ||\n data.message?.content ||\n data.text ||\n data.output ||\n data.content;\n\nif (textoIa && !String(textoIa).includes('Welcome to the OpenAI API')) {\n return [\n {\n json: {\n text: textoIa\n }\n }\n ];\n}\n\nconst indicadores = $('Code - Calcular indicadores').first().json;\n\nfunction formatarRanking(titulo, dados, sufixo = '') {\n const linhas = Object.entries(dados || {})\n .slice(0, 5)\n .map(([nome, total]) => `- ${nome}: ${total}${sufixo}`);\n\n return [`*${titulo}*`, ...(linhas.length ? linhas : ['- Sem registros'])].join('\\n');\n}\n\nconst erroOpenAi = data.error?.message || data.message || 'OpenAI indisponível ou com limite de uso no momento.';\n\nconst texto = [\n '*Resumo diário do suporte*',\n '',\n `Data do relatório: ${indicadores.data_filtro || 'Não informada'}`,\n '',\n '*Resumo geral*',\n `- Chamados abertos no dia: ${indicadores.chamados_abertos_hoje}`,\n `- Chamados fechados no dia: ${indicadores.chamados_fechados_hoje}`,\n `- Pendentes envolvidos no período: ${indicadores.chamados_pendentes}`,\n `- Alta prioridade ainda pendentes: ${indicadores.chamados_alta_prioridade_pendentes}`,\n '',\n formatarRanking('Categorias mais recorrentes', indicadores.chamados_por_categoria),\n '',\n formatarRanking('Clientes com maior volume', indicadores.chamados_por_cliente, ' chamados'),\n '',\n '*Diagnóstico da coleta*',\n `- Páginas consultadas: ${indicadores.paginas_consultadas}`,\n `- Registros recebidos do GLPI: ${indicadores.total_recebido_glpi}`,\n `- Tickets únicos analisados: ${indicadores.total_retornado_glpi}`,\n '',\n '*Pontos de atenção*',\n indicadores.chamados_alta_prioridade_pendentes > 0\n ? `- Existem ${indicadores.chamados_alta_prioridade_pendentes} chamados de alta prioridade ainda pendentes.`\n : '- Não há chamados de alta prioridade pendentes nos dados filtrados.',\n '',\n '*Recomendação para amanhã*',\n '- Priorizar chamados pendentes e de maior prioridade.',\n '- Verificar categorias e clientes com maior recorrência.',\n '',\n `_Resumo gerado sem IA porque a OpenAI retornou erro: ${erroOpenAi}_`\n].join('\\n');\n\nreturn [\n {\n json: {\n text: texto\n }\n }\n];"
},
"id": "code-preparar-slack",
"name": "Code - Preparar mensagem Slack",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2020,
320
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config - GLPI e Slack').first().json.slack_webhook_url }}",
"sendBody": true,
"contentType": "json",
"specifyBody": "keypair",
"bodyParameters": {
"parameters": [
{
"name": "text",
"value": "={{ $json.text }}"
}
]
},
"options": {}
},
"id": "http-slack",
"name": "HTTP - Slack enviar mensagem",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2260,
320
]
},
{
"parameters": {
"url": "={{ $('Config - GLPI e Slack').first().json.glpi_url.replace(/\\/$/, '') + '/apirest.php/killSession' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Session-Token",
"value": "={{ $('HTTP - GLPI initSession').first().json.session_token }}"
},
{
"name": "App-Token",
"value": "={{ $('Config - GLPI e Slack').first().json.glpi_app_token }}"
}
]
},
"options": {}
},
"id": "http-kill-session",
"name": "HTTP - GLPI killSession",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2500,
320
]
}
],
"pinData": {},
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Config - GLPI e Slack",
"type": "main",
"index": 0
}
]
]
},
"Config - GLPI e Slack": {
"main": [
[
{
"node": "HTTP - GLPI initSession",
"type": "main",
"index": 0
}
]
]
},
"HTTP - GLPI initSession": {
"main": [
[
{
"node": "Code - Gerar paginas GLPI",
"type": "main",
"index": 0
}
]
]
},
"HTTP - GLPI buscar tickets": {
"main": [
[
{
"node": "Code - Filtrar chamados de hoje",
"type": "main",
"index": 0
}
]
]
},
"Code - Filtrar chamados de hoje": {
"main": [
[
{
"node": "Code - Calcular indicadores",
"type": "main",
"index": 0
}
]
]
},
"Code - Calcular indicadores": {
"main": [
[
{
"node": "OpenAI - Gerar resumo",
"type": "main",
"index": 0
}
]
]
},
"OpenAI - Gerar resumo": {
"main": [
[
{
"node": "Code - Preparar mensagem Slack",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar mensagem Slack": {
"main": [
[
{
"node": "HTTP - Slack enviar mensagem",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Slack enviar mensagem": {
"main": [
[
{
"node": "HTTP - GLPI killSession",
"type": "main",
"index": 0
}
]
]
},
"Code - Gerar paginas GLPI": {
"main": [
[
{
"node": "HTTP - GLPI buscar tickets",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}
+10
View File
@@ -0,0 +1,10 @@
N8N_PORT=5678
N8N_HOST=localhost
N8N_PROTOCOL=http
GENERIC_TIMEZONE=America/Sao_Paulo
TZ=America/Sao_Paulo
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=troque-esta-senha
N8N_ENCRYPTION_KEY=troque-esta-chave-com-32-caracteres
+1
View File
@@ -0,0 +1 @@
.env
File diff suppressed because one or more lines are too long
+134
View File
@@ -0,0 +1,134 @@
# Coordenador GLPI
Automação n8n que analisa os chamados abertos no GLPI todo dia útil, classifica
cada um com IA (contexto real do chamado, não só metadados) e envia no Slack os
10 chamados que realmente precisam de ação humana hoje.
O repositório tem duas gerações do workflow:
- **V2** (`n8n/`, `logic/`) — versão atual, em produção. Todo este README
descreve o V2.
- **V1** (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`, na raiz) —
versão legada, mantida apenas como rollback. Nunca foi importada nem ativada
na instância n8n de produção; existe só como referência histórica e plano B.
## Estado atual (verificado em 2026-08-25)
```
Workflow: [DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow) (id y6JFEnp7Z2IJEUe2)
active: true
SEND_ENABLED: true
ENVIRONMENT: production
FORCE_SEND: false (execução manual nunca envia de verdade sem essa flag)
Schedule: seg-sex 08:00, timezone America/Sao_Paulo (cron "0 8 * * 1-5")
Testes: 171/171 (node --test)
```
O nome do workflow no n8n ainda carrega o prefixo histórico `[DEV]` /
`(Shadow)` — isso é só rótulo visual, não reflete mais o estado real (ativo,
`SEND_ENABLED=true`, `ENVIRONMENT=production`). Renomear é seguro a qualquer
momento, mas não foi feito para não gerar um diff de auditoria sem função.
Se essa seção estiver desatualizada, o estado de verdade é sempre o que o n8n
responde (`n8n_get_workflow` / `n8n_manage_datatable`), nunca este arquivo.
## Arquitetura
```
GLPI (MySQL, somente leitura)
-> candidatos (pool configurável, sem corte artificial)
-> normalização de texto (decodifica HTML/entities do GLPI)
-> cache de classificação (Data Table, chave = hash do contexto + versão do classificador)
-> IA (OpenAI) classifica só quem mudou de contexto ou nunca foi analisado
-> score determinístico + seleção Top 10 com diversidade por grupo
-> guard de envio (SEND_ENABLED, FORCE_SEND, idempotência por hash da mensagem)
-> Slack (mensagem real, sem rodapé de atribuição do n8n)
-> shadow-diff contra a query legada do V1 (auditoria, não bloqueia envio)
```
Cada etapa grava em uma Data Table do n8n (`coordenador_glpi_memoria`,
`coordenador_glpi_envios_digest`, `coordenador_glpi_shadow_runs`) — não há
escrita nenhuma no banco do GLPI. Toda query GLPI é somente leitura; isso é
testado (`tools/build-workflow.test.js` varre `sql/*.sql` e o SQL gerado
procurando `INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE`).
### Por que a IA classifica o chamado, não só pontua
Cada chamado recebe uma classificação semântica (`ACTIONABLE_NOW`,
`UNASSIGNED_ACTIONABLE`, `WAITING_REQUESTER`, `WAITING_VENDOR`,
`WAITING_APPROVAL`, `SCHEDULED`, `DEPENDENCY_BLOCKED`, `NEEDS_COORDINATION`,
`PROJECT_LONG_TERM`, `DUPLICATE_OR_OBSOLETE`, `INSUFFICIENT_CONTEXT`) mais um
`next_action` concreto (nunca frases genéricas tipo "revisar chamado" — isso é
validado e rejeitado, ver `logic/classification.js`). O prompt
(`n8n/nodes/prepare-ai-prompt.node.js`) trata cronologia dos apontamentos como
obrigatória e distingue "precisa contatar alguém" (acionável agora) de "já
contatou e está esperando resposta" (`WAITING_*`).
### Determinismo do Top 10
`logic/diversity.js` desempata candidatos com score igual por `ticket.id`
(ascendente) — sem isso, ordens de chegada diferentes entre execuções
produziam Top10 diferentes para o mesmo dia, o que já causou um envio real
duplicado em teste (ver `docs/superpowers/notes/2026-08-25-relatorio-correcao-final.md`,
seção 5). Regressão coberta em `logic/diversity.test.js`.
## Arquivos
```
logic/*.js Lógica pura, testável com node:test (sem I/O, sem n8n)
n8n/nodes/*.node.js Drivers finos que rodam dentro de Code nodes do n8n
(cabeçalho "// @requires a.js b.js" declara quais
módulos de logic/ precisam ser inlinados)
n8n/workflow.template.json Fonte de verdade da estrutura do workflow
(marcadores __BUILD__:arquivo.js são substituídos
pelo conteúdo de logic/ na hora do build)
n8n/deployed/coordenador-glpi-v2.n8n.json Artefato gerado, importável no n8n
tools/build-workflow.js Inlina logic/ + n8n/nodes/ dentro do template
tools/build-workflow.test.js Regressões de build (dependência transitiva,
alwaysOutputData, columns.mappingMode, etc.)
sql/ Queries GLPI usadas como referência/validação manual
docs/superpowers/ Specs, planos e relatórios de execução (histórico,
não é para reescrever — cada nota é um registro de
um momento específico do projeto)
```
## Build
```bash
node tools/build-workflow.js
node --test
```
O build só precisa rodar de novo quando `logic/*.js`, `n8n/nodes/*.node.js` ou
`n8n/workflow.template.json` mudarem. O artefato gerado
(`n8n/deployed/coordenador-glpi-v2.n8n.json`) **não é a fonte de verdade do
ambiente de produção** — o workflow ativo no n8n foi promovido diretamente via
API (`n8n_update_partial_workflow`) e hoje diverge do template em 3 valores de
configuração de propósito:
| campo | template (default de deploy limpo) | live (produção) |
|---|---|---|
| `ENVIRONMENT` | `dev` | `production` |
| `SEND_ENABLED` | `false` | `true` |
| `FORCE_SEND` | `false` | `false` |
Isso é intencional: o template continua "seguro por padrão" para qualquer
reimportação futura (nunca envia sozinho), e a promoção para produção é um ato
explícito e auditável via `n8n_update_partial_workflow`, não algo que um
rebuild acidental possa reverter. Se um dia o workflow live for recriado do
zero a partir do template, repita a promoção manualmente (ver comando na nota
`2026-08-25-relatorio-finalizacao-producao.md`).
## Rollback
O V1 (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`) nunca foi
importado no n8n de produção — não há nada para "reverter para V1", ele
continua disponível como plano B caso seja necessário abandonar o V2. Para
desativar o V2 sem tocar em nada mais: `SEND_ENABLED=false` no node
`Config - Coordenador GLPI v2` e/ou desativar o workflow.
## Credenciais e segredos
Nenhum segredo vive neste repositório. Tudo (Slack bot token, OpenAI API key,
credencial MySQL do GLPI) é gerenciado pelo credential store do próprio n8n e
referenciado nos nodes só por `{id, name}`.
+22
View File
@@ -0,0 +1,22 @@
services:
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n-coordenador-glpi
restart: unless-stopped
ports:
- "${N8N_PORT:-5678}:5678"
environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo}
- TZ=${TZ:-America/Sao_Paulo}
- N8N_HOST=${N8N_HOST:-localhost}
- N8N_PORT=5678
- N8N_PROTOCOL=${N8N_PROTOCOL:-http}
- N8N_BASIC_AUTH_ACTIVE=${N8N_BASIC_AUTH_ACTIVE:-true}
- N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER:-admin}
- N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD:-troque-esta-senha}
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-troque-esta-chave-com-32-caracteres}
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
+109
View File
@@ -0,0 +1,109 @@
# Estado do Projeto — Coordenador GLPI
Última atualização: 2026-09-27 (revisão de documentação; último estado live
verificado por execução real: 2026-08-25).
## O que é
Workflow n8n que roda seg-sex 08:00 (`America/Sao_Paulo`), lê chamados abertos
do GLPI (MySQL, read-only), classifica cada um com IA e envia no Slack os 10
que precisam de ação humana hoje. Duas gerações no repo: **V2** (ativo,
`n8n/` + `logic/`) e **V1** (legado, `Coordenador GLPI - Chamados Abertos
Slack.n8n.json` na raiz — nunca foi importado no n8n de produção, existe só
como rollback).
## Estado de produção (n8n instance, workflow id `y6JFEnp7Z2IJEUe2`)
Confirmado por `n8n_get_workflow` em 2026-08-25 após ativação explícita
autorizada pelo usuário ("pode ativar em produção"):
- `active: true`
- Config node: `ENVIRONMENT=production`, `SEND_ENABLED=true`, `FORCE_SEND=false`
- Schedule Trigger conectado, cron `0 8 * * 1-5`, `settings.timezone=America/Sao_Paulo`
- 43 nodes, nenhum webhook temporário de debug presente
- Credenciais live: MySQL `GLPI` (id `dsVj1nmOIXRvix9n`), Slack `Slack - Bot
Coordenador (Chamados Suporte)` (id `lNqwBBrtLfkRqw2X`), OpenAI `OpenAI -
Coordenador GLPI` (id `WUGriAn5FgCiLza2`)
- Slack destino: canal `chamados-suporte` (id `C0BJNBTPFT5`)
**Este arquivo não substitui uma verificação live.** Se for tomar qualquer
decisão que dependa do estado atual (reativar, mudar config, investigar um
envio), reconfirme com `n8n_get_workflow` / `n8n_executions` antes — não
assuma que nada mudou desde 25/08 só porque este arquivo diz isso.
### Limpeza pós-ativação (2026-08-25)
Durante a fase de correção final, execuções de teste reais enviaram mensagens
ao Slack para ~37 tickets, gravando `last_sent_at`/`times_sent`/
`consecutive_days_sent` na Data Table `coordenador_glpi_memoria`. Isso foi
resetado manualmente (`last_sent_at`, `times_sent`, `times_sent_30d`,
`consecutive_days_sent` → `null` para essas linhas) para que o primeiro envio
real de produção não sofresse penalidade de cooldown por causa do teste.
`last_context_hash`/`ai_classification`/`next_action` foram preservados (não
há motivo para reclassificar de novo o que não mudou no GLPI).
## Arquitetura (resumo — detalhe completo no README.md)
```
GLPI (MySQL read-only) -> candidatos -> normalização -> cache de classificação
(Data Table, chave = hash do contexto + AI_CLASSIFIER_VERSION) -> IA (OpenAI,
só quem mudou) -> score determinístico -> Top10 com diversidade por grupo
(desempate por ticket.id — ver "bugs reais corrigidos") -> guard de envio
(SEND_ENABLED + FORCE_SEND + idempotência por hash da mensagem) -> Slack
-> shadow-diff contra query legada do V1 (auditoria, não bloqueia)
```
`logic/*.js` é lógica pura testável (`node --test`, 171/171 passando em
2026-09-27). `n8n/nodes/*.node.js` são drivers finos que `tools/build-workflow.js`
inlina dentro de `n8n/workflow.template.json` para gerar
`n8n/deployed/coordenador-glpi-v2.n8n.json`.
**O template local (`ENVIRONMENT=dev`, `SEND_ENABLED=false`) diverge de
propósito do live (`production`/`true`)** — ver README.md seção "Build" para
a tabela completa e o motivo (template = default seguro para reimportação;
promoção para produção é ato manual e auditável, não algo que rebuild reverte).
## Bugs reais corrigidos (histórico — não reabrir sem nova evidência)
1. `WAITING_*`/bloqueados entrando no Top10 via `contextChanged` — corrigido
em `logic/cooldown.js`.
2. Rótulo fixo "Validar e executar" sempre vencendo o `next_action` real da
IA — corrigido em `logic/message.js` (`shortActionLabel`).
3. **Crítico**: Top10 não-determinístico (empate de score desempatado pela
ordem de chegada dos itens, que o n8n não garante entre branches
paralelos) causou envio real duplicado com conteúdo diferente no mesmo
dia. Corrigido com desempate por `ticket.id` em `logic/diversity.js`.
Regressão em `logic/diversity.test.js`.
4. `alwaysOutputData` faltando em `Data Table - Buscar Envios Recentes`
quebraria o caminho feliz todo dia sem envio prévio (0 linhas = n8n não
roda os nodes seguintes).
5. `columns.mappingMode` faltando nos nodes de insert da Data Table de envio
(`Cannot convert undefined or null to object`).
6. Rodapé de atribuição automática do n8n no Slack (`includeLinkToWorkflow`)
— desligado explicitamente.
7. Falta de escopo OAuth (`channels:join`) causando `not_in_channel` mesmo
com o bot já no canal — resolvido reinstalando o app Slack com o escopo.
Detalhe completo de cada um, com execução real que provou a causa raiz, em
`docs/superpowers/notes/2026-08-25-relatorio-correcao-final.md` e
`2026-08-25-relatorio-finalizacao-producao.md`.
## Restrições que continuam valendo (não renegociar sem pedido explícito do usuário)
- GLPI é estritamente somente leitura — nenhuma escrita, nunca.
- V1 nunca é apagado; é o rollback.
- Não recalibrar pesos de score/diversidade/pool sem bug real comprovado.
- Segredos só via credential store do n8n — nunca em arquivo do repo.
- Execução manual nunca envia de verdade sem `FORCE_SEND=true` (proteção
contra teste acidental em produção).
## Onde olhar primeiro
- Dúvida sobre comportamento atual do workflow → `n8n_get_workflow` (live),
não os arquivos deste repo (o repo é a fonte de verdade da *lógica*, não
necessariamente da config live, ver seção "Build" do README).
- Dúvida sobre por que uma decisão de design foi tomada →
`docs/superpowers/specs/2026-08-24-coordenador-glpi-v2-design.md` e
`docs/superpowers/plans/2026-08-24-coordenador-glpi-v2.md`.
- Dúvida sobre um bug específico já corrigido → notas datadas em
`docs/superpowers/notes/`, mais recente primeiro.
@@ -0,0 +1,184 @@
# Coordenador GLPI V2 — Relatório do Ciclo de Correção (24/08/2026)
Continuação do projeto existente (não é nova arquitetura, não é reescrita). Este ciclo corrigiu problemas encontrados na primeira execução real (execução `13599`, documentada em `2026-08-24-relatorio-final.md`) e produziu duas novas execuções reais contra o GLPI de produção em modo shadow, sem nenhuma alteração de produção.
---
## 1. RECOVERY
- Repositório local (`D:\PROJETOS\Coordenador GLPI\Coordenador GLPI`) recuperado do estado do ciclo anterior: branch `feature/coordenador-glpi-v2`, workflow DEV `y6JFEnp7Z2IJEUe2` já existente no n8n (`https://devn8n.gtigrupo.com.br`).
- Estado real do workflow ao iniciar este ciclo (confirmado via `n8n_get_workflow`, não presumido): **37 nodes**, `active: false`, branch de debug ainda presente, `glpi_base_url` ainda `https://glpi.suaempresa.com.br` (placeholder do V1), `Code - Reusar Classificacao em Cache` ainda com o bug `is_fallback: false` hardcoded.
- Suíte local: 101/101 passando no início deste ciclo (54 herdados + 47 daquele ponto em diante).
- Confirmado que nada foi alterado em produção nem no cron entre o fim do ciclo anterior e o início deste.
## 2. CORREÇÕES (por bug/arquivo)
| # | Bug | Arquivo(s) | Correção |
|---|-----|-----------|----------|
| 1 | Texto do GLPI chega HTML/entity-escaped (às vezes escapado 2x) e degrada a leitura da IA | `logic/normalize.js` (novo), `logic/context.js`, `sql/candidatos_v2_glpi.sql` | Normalizador iterativo (`normalizeGlpiText`, até 5 passes) decodifica entidades numéricas/hex/nomeadas e remove tags HTML, sem apagar datas/nomes/números/e-mails/domínios. Aplicado antes do hash e antes do contexto da IA. `t.content AS descricao` passou a ser buscado (nunca era buscado antes). |
| 2 | Fallback (IA nunca analisou) era representado como `ACTIONABLE_NOW` com `confidence: 0` — competia como se fosse confirmado | `logic/fallback.js`, `logic/classification.js` | Novo estado técnico `AI_FALLBACK` (fora do domínio de 11 classes que a IA pode retornar). Nunca recebe bônus de acionabilidade. |
| 3 | `AI_FALLBACK` e `INSUFFICIENT_CONTEXT` disputavam o Top10 normalmente | `logic/cooldown.js` (`applyFallbackPolicy`, novo), `logic/score.js` | Política centralizada: ambos ficam fora do Top10 por padrão; só entram via exceção crítica determinística (`sla_vencido + prioridade alta` OU `sem_tecnico + prioridade alta`), com `decision_reason` explícito (`ai_fallback_critical_override` / `insufficient_context_critical_override`). |
| 4 | Bug de cache: `Code - Reusar Classificacao em Cache` fixava `is_fallback: false` sempre, mesmo quando a classificação cacheada era `AI_FALLBACK` | `n8n/nodes/reuse-cached-classification.node.js` | `is_fallback` agora é derivado do valor cacheado (`m.ai_classification === 'AI_FALLBACK'`) — sem isso, a política do item 3 seria furada silenciosamente numa segunda execução. |
| 5 | IA às vezes devolve a string `"null"`/`"none"`/`"n/a"`/`""` em vez de `null` real | `logic/classification.js` | `normalizeNullableString`, aplicado só na camada de validação, em `blocker_type`/`waiting_for`. |
| 6 | Webhook de debug desnecessário | `n8n/workflow.template.json`, `n8n/nodes/debug-panel.node.js` (removido) | Removidos os 4 nodes (`Debug - Ver Painel`, `Data Table - Buscar Shadow Runs Recentes`, `Code - Montar Painel Debug`, `Debug - Responder`) e suas conexões. Debug passa a depender de execution data do n8n, Data Tables e o shadow diff. |
| 7 | `glpi_base_url` ainda era o placeholder do V1 (`glpi.suaempresa.com.br`), não anonimização de relatório | `n8n/workflow.template.json` | Confirmado como placeholder real (nunca configurado) lendo o config de um workflow irmão **ativo e real** na mesma instância (`GLPI x WhatsApp`). Corrigido para `https://glpi.gtigrupo.com.br`, vindo só do Config node — nunca hardcoded no SQL (`{GLPI_BASE_URL}` placeholder mantido). |
| 8 | Contexto da IA era uma string única sem estrutura | `logic/context.js` (`buildAiContextText`) | Seções delimitadas: `TITULO`, `DESCRICAO ORIGINAL`, bloco `STATUS/TECNICO/GRUPO/PRIORIDADE/SLA`, `FOLLOW-UPS`, `TAREFAS`. Orçamento de caracteres mantido (`AI_MAX_CONTEXT_CHARS`, não alterado). |
| 9 | **Bug novo, encontrado e corrigido durante este ciclo**: dependência transitiva ausente | `n8n/nodes/merge-context-hash.node.js`, `n8n/nodes/normalize-candidates.node.js`, `n8n/nodes/prepare-ai-prompt.node.js`, `tools/build-workflow.test.js` | `context.js` passou a fazer `require('./normalize')`, mas `tools/build-workflow.js` só inlina o que o header `@requires` de cada node lista — não resolve deps transitivas (mesma convenção já usada em `compute-score.node.js`, que lista `cooldown.js score.js` juntos). 3 nodes ficaram chamando `normalizeGlpiText` sem a função definida no mesmo escopo — sintaxe válida, mas `ReferenceError` garantido em runtime. **Encontrado antes de afetar o Run A real** (a primeira tentativa de sincronizar para o DEV live expôs o problema porque o hash não mudava como esperado — investigação levou à causa raiz). Corrigido adicionando `normalize.js` aos 3 headers + teste de regressão novo. |
## 3. NORMALIZAÇÃO GLPI — antes/depois (dados reais, chamado #37389 e #36530)
**Chamado #37389 — `descricao` bruta no banco (`t.content`):**
```
&#60;ol&#62;
&#60;li&#62;Realizar um inventário das máquinas&#60;/li&#62;
&#60;li&#62;Alinhar o dia para poder fazer a recolha das máquinas &#60;/li&#62;
&#60;li&#62;E ver se tem necessidade de um upgrade nás que precisarem&#60;/li&#62;
&#60;/ol&#62;
```
**Depois de `normalizeGlpiText`:**
```
Realizar um inventário das máquinas
Alinhar o dia para poder fazer a recolha das máquinas
E ver se tem necessidade de um upgrade nás que precisarem
```
**Chamado #36530 — bruta (com atributos HTML e e-mail/domínio real):**
```
&#60;p&#62;Tem também a questão do e-mail @institutoirisma.com.br&#60;/p&#62;
&#60;ul data-list-tree="true" data-indent="1" data-border="0"&#62;
&#60;li&#62;ver com ele se pode ser um email básico...&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;Jhon pediu para deixar 10 contas&#60;/p&#62;
```
**Depois:**
```
Tem também a questão do e-mail @institutoirisma.com.br
ver com ele se pode ser um email básico, e ele fazer reuniões com os colaboradores mandando link e o povo acessando pelo email pessoal
Jhon pediu para deixar 10 contas
```
Acentuação, e-mail, domínio e nome próprio (Jhon) preservados intactos — só entidades/tags foram removidas.
## 4. FALLBACK — regra antiga vs nova
- **Antes:** fallback (IA desligada/erro/timeout/JSON inválido/cortado pelo cap) virava `classification: 'ACTIONABLE_NOW'`, `actionable_today: true`, `confidence: 0` — competia no ranking como se fosse uma conclusão real da IA.
- **Agora:** `classification: 'AI_FALLBACK'` (fora do domínio de 11 classes da IA), `actionable_today: false`, nunca recebe bônus de acionabilidade (`actionabilityPoints = 0` quando `is_fallback`), e é **excluído do Top10 por padrão**. Só entra via `applyFallbackPolicy` quando o chamado atende à exceção crítica (SLA vencido + prioridade alta, ou sem técnico + prioridade alta), registrando `decision_reason: 'ai_fallback_critical_override'`.
## 5. INSUFFICIENT_CONTEXT — tratamento
Mesma política do item acima (`applyFallbackPolicy`): fora da disputa normal do Top10 por padrão (`decision_reason: 'insufficient_context_no_override'`, `excludedFromTop10: true`), só entra por `insufficient_context_critical_override`. Adicionalmente, todo chamado classificado `INSUFFICIENT_CONTEXT` é listado em `dataQualityReview` (fila interna de auditoria, gravada dentro do `summary` do shadow run — não é enviada a ninguém) com `context_size`, `has_description`, `followup_count`, `task_count` — para identificar problemas reais de preenchimento no GLPI, não just IA sendo conservadora.
## 6. TESTES
**Antes deste ciclo:** 101/101
**Agora:** **102/102** (nenhum teste antigo quebrou; 48 testes novos desde o início do ciclo — 47 do desenvolvimento das correções + 1 de regressão para o bug de dependência transitiva encontrado nesta sessão)
Novos arquivos/blocos de teste deste ciclo:
- `logic/normalize.test.js` (12 testes: HTML simples, escapado 1x/2x, entidades nomeadas, texto puro, acentuação, códigos técnicos preservados, vazio/null, colapso de espaços/linhas, limite de iteração, duas codificações diferentes → mesmo resultado)
- `logic/context.test.js` (+testes de `descricao`, texto de followups/tasks normalizado, presença de seções)
- `logic/hash.test.js` (+testes: mesmo conteúdo com encoding HTML diferente → mesmo hash; edição real de followup → hash muda)
- `logic/fallback.test.js` (reescrito, 8 testes: `AI_FALLBACK` nunca é uma classificação válida da IA; todos os motivos de fallback produzem `classification: 'AI_FALLBACK'`, `actionable_today: false`)
- `logic/classification.test.js` (+testes: `"null"`/`"None"`/`"N/A"`/`""` → `null` real; `next_action` vazio/só-espaço é rejeitado)
- `logic/cooldown.test.js` (+8 testes: crítico por `sem_tecnico`+prioridade sem SLA vencido; não-crítico não conta; `AI_FALLBACK`/`INSUFFICIENT_CONTEXT` comum bloqueado vs crítico libera; classificação real nunca é tocada pela política)
- `logic/score.test.js` (+4 testes: `AI_FALLBACK` comum excluído / crítico entra; `INSUFFICIENT_CONTEXT` comum excluído / crítico entra)
- `logic/dataQuality.test.js` (novo, 4 testes)
- `tools/build-workflow.test.js` (+4 testes: sem webhook de debug; `glpi_base_url` não é mais o placeholder; SQL nunca tem domínio fixo; **todo node que chama função de `normalize.js` tem essa função inlined** — regressão do bug #9)
## 7. RUN A (dados reais, execução `14259`, 24/08/2026 23:04 UTC)
Mesmos limites de antes: `CANDIDATE_POOL_SIZE=60`, `AI_MAX_CANDIDATES=30`. `coordenador_glpi_memoria` **não foi limpa** (decisão do usuário) — a expansão do fingerprint do hash (título/descrição/texto de followups-tasks) invalidou o cache antigo naturalmente (confirmado: `cache_hits: 0` no Run A).
| Métrica | Antes (execução 13599) | Run A (execução 14259) |
|---|---|---|
| candidate_count | 45 | 45 |
| INSUFFICIENT_CONTEXT | 20 | 16 |
| WAITING_VENDOR | 6 | 8 |
| WAITING_REQUESTER | 4 | 6 |
| ACTIONABLE_NOW | 0 | 0 |
| UNASSIGNED_ACTIONABLE | 0 | 0 |
| AI_FALLBACK | *(não existia — era contado como ACTIONABLE_NOW)* | 15 |
| ai_requested / ai_success / ai_errors | não medido | 30 / 30 / 0 |
| ai_fallback_cap_count | não medido | 15 (= 45 candidatos − 30 cap) |
| critical_override_count | não medido | 3 |
| top10_count / blocked_count | 10 / não medido | 10 / 35 |
**Reportando com honestidade, como instruído:** `ACTIONABLE_NOW` e `UNASSIGNED_ACTIONABLE` continuam em **0** mesmo com contexto limpo e estruturado. Não foi forçada nem inventada nenhuma melhoria nesse número. O ganho real e mensurável deste ciclo não é "mais chamados acionáveis" — é: (a) zero chamados fantasiosamente rotulados `ACTIONABLE_NOW` quando a IA nunca analisou nada (15 chamados que antes inflavam esse número), e (b) `next_action`/`reason` específicos e evidenciados por chamado em vez do texto genérico repetido "Revisar chamado manualmente" 6 vezes na mensagem anterior — por exemplo, o Top10 do Run A trouxe: *"Aguardar análise de André"*, *"Aguardar confirmação de Tamires"*, *"aguardar retorno da solicitante"*, *"aguardar fornecimento de baterias"*, *"aguardar resposta do solicitante"*, *"aguardar resposta de Patricia"* — seis próximas-ações distintas e rastreáveis, cada uma com evidência textual do próprio chamado.
Isso sugere que a causa raiz do zero em `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE` não é mais ruído de HTML — é que, nesta amostra real de 45 candidatos, genuinamente nenhum tem uma ação executável hoje sem depender de terceiro (técnico, fornecedor ou solicitante). Investigar o prompt da IA (instrução 9 da missão) só faria sentido agora, com contexto limpo confirmado — não foi alterado neste ciclo, por instrução explícita.
## 8. REINCIDENTES (chamados #37691, #37389, #34419, #36530, #33492, #36488, #38319 — todos presentes no Run A)
| Ticket | Classificação | Confiança | Fallback? | Blocker | Waiting for | Next action | Decision reason | Top10? | Score | Cache | Hash mudou? |
|---|---|---|---|---|---|---|---|---|---|---|---|
| #37389 | INSUFFICIENT_CONTEXT | 0.3 | não | — | — | Definir data para recolha | insufficient_context_**critical**_override | **sim** | 60 | ia (fresh) | sim |
| #34419 | WAITING_VENDOR | 0.7 | não | — | Rafael Brito | Aguardar migração de e-mails | — | sim | -90 | ia (fresh) | sim |
| #38319 | WAITING_VENDOR | 0.8 | não | — | fornecimento de baterias | aguardar fornecimento de baterias | — | sim | -90 | ia (fresh) | sim |
| #36488 | WAITING_VENDOR | 0.8 | não | hardware replacement | Patricia | aguardar resposta de Patricia | — | sim | -90 | ia (fresh) | sim |
| #36530 | INSUFFICIENT_CONTEXT | 0.2 | não | — | — | Definir próximo passo com o solicitante | insufficient_context_no_override | não | 60 | ia (fresh) | sim |
| #33492 | INSUFFICIENT_CONTEXT | 0.2 | não | — | — | Definir próxima ação | insufficient_context_no_override | não | 60 | ia (fresh) | sim |
| #37691 | WAITING_REQUESTER | 0.7 | não | — | informações do solicitante | aguardar informações do solicitante | — | não* | -90 | ia (fresh) | sim |
\* #37691 não entrou no Top10 do Run A (ficou de fora por score, não por exclusão de política — `excludedFromTop10: false`), mas entrou no Top10 do Run B quando 6 vagas abriram por cooldown (ver seção 9).
Evidência textual real usada pela IA (recorte de `evidence`), por chamado:
- **#37389**: "Realizar um inventário das máquinas"; "Alinhar o dia para poder fazer a recolha das máquinas"; "SLA vencido: true"; "Dias sem interacao: 10"
- **#34419**: "Migra os E-mail da IMDI"; "Solicitar Rafael para fazer a migração de E-mails"; "STATUS: Planejado"
- **#38319**: "Nobreak intelbras 1200va numero 2 não segura carga, aguardando baterias para repor."
- **#36488**: "foi feito a adição de um novo pente de memoria ram, mas o computador estava com a carcaça extremamente prejudicada"; "passado as informações a Patricia do que seria nescessario trocar"
- **#36530**: "SLA vencido: true"; "Dias sem interacao: 199"; "STATUS: Em atendimento"
- **#33492**: "Instalar novo servidor da IMDI"; "STATUS: Planejado"; "SLA vencido: true"; "Dias sem interacao: 291"
- **#37691**: "SLA vencido: true"; "STATUS: Planejado"; "TAREFAS - Ver o que vai precisar e da inicio!"
Nota: #37389, #36530 e (indiretamente) os dois chamados `sem_tecnico` do Top10 são os 3 `critical_override_count` do Run A — a exceção determinística está funcionando e é auditável via `decision_reason`.
## 9. RUN B — prova de cache (execução `14263`, ~1 minuto depois do Run A, sem mudança intencional de dados)
| Métrica | Run A | Run B |
|---|---|---|
| ai_requested | 30 | **0** |
| cache_hits | 0 | **45** |
| ai_fallback_by_reason | `ai_max_candidates_cap: 15` | `cached_ai_fallback: 15` |
| top10_count | 10 | 5 |
**Zero chamadas de IA no Run B** — prova direta do mecanismo de cache/hash (nenhuma chamada nova era esperada nem foi forçada; o resultado é honesto).
**Os 15 fallbacks do cap continuam corretamente marcados como fallback ao reaproveitar do cache** (`is_fallback: true`, `fallback_reason: 'cached_ai_fallback'`) — prova direta de que a correção do bug #4 (seção 2) funciona: antes dela, um `AI_FALLBACK` cacheado voltaria como `is_fallback: false` e furaria a política de exclusão silenciosamente numa segunda execução.
**Prova de cooldown correto** — o Top10 caiu de 10 para 5 itens, e a razão é exatamente a esperada:
- `#34419` e `#36488` saíram com `waiting_next_review` (bloqueados porque `next_review_at` da IA ainda não venceu e o contexto não mudou).
- `#37945`, `#37379`, `#37073`, `#38164` (todos `WAITING_VENDOR`/`WAITING_REQUESTER`) saíram do Top10 porque, com `contextChanged: false` (veio do cache), caem em `BLOCKING_CLASSIFICATIONS` — comportamento correto: chamado que já apareceu, sem mudança real, não deve voltar a cada execução.
- `#37691` (`WAITING_REQUESTER`, evidência acima) **subiu para o Top10** no Run B ocupando uma vaga aberta — redistribuição correta por score, não um bug.
- Nenhum item mudou de classificação (`INSUFFICIENT_CONTEXT`: 16→16, `WAITING_VENDOR`: 8→8, `WAITING_REQUESTER`: 6→6, `AI_FALLBACK`: 15→15) — puro efeito de cooldown, não reclassificação.
- `SCHEDULED` e `NEEDS_COORDINATION`: nenhum candidato desta amostra caiu nessas classes em nenhum dos dois runs — não há caso real para validar aqui além do que os testes unitários já cobrem (`logic/score.test.js`, `logic/cooldown.test.js`).
## 10. SHADOW DIFF (V2 × Legado, dados reais)
Legado (V1, mesmo nos dois runs — dados reais não mudaram no intervalo): `[37389, 34419, 36530, 33492, 36488, 36897, 38611, 38583, 38658, 38642]`
**Run A vs Legado:** adicionados pelo V2 `37945, 37379, 37073, 38319, 38164`; removidos do legado `36530, 33492, 36897, 38611, 38642` — todos com `motivo` explícito (`insufficient_context_no_override` / `ai_fallback_no_override`).
**Run B vs Legado:** adicionados `37691, 38319`; removidos `34419, 36530, 33492, 36488, 36897, 38611, 38642` (motivos: `waiting_next_review` × 2, `insufficient_context_no_override` × 3, `ai_fallback_no_override` × 2).
## 11. WEBHOOK DEBUG
**Removido: SIM.** `Debug - Ver Painel`, `Data Table - Buscar Shadow Runs Recentes` (branch de debug), `Code - Montar Painel Debug` e `Debug - Responder` não existem mais no template nem no workflow live (33 nodes, confirmado via `n8n_get_workflow`). Teste de regressão (`tools/build-workflow.test.js`) garante que nenhum node `n8n-nodes-base.webhook` sobra no artefato gerado.
**Sobre o webhook temporário usado para rodar o Run A/Run B:** reutilizei a limitação já documentada no ciclo anterior — `n8n_test_workflow` só dispara workflows com trigger webhook/form/chat, e exige `active: true`. Um node `TEMP - Webhook Run A` foi adicionado, o workflow foi ativado, disparado duas vezes (Run A e Run B), e imediatamente **desativado e removido** ao final (`active: false`, node removido, `cleanStaleConnections` aplicado) — confirmado via `n8n_get_workflow` (nodeCount voltou a 33).
## 12. PRODUÇÃO
- Produção alterada: **NÃO**
- Envio real realizado: **NÃO** (nenhuma mensagem saiu do ambiente DEV)
- Escrita nas tabelas GLPI: **NÃO** (todas as queries GLPI são `SELECT`)
- Cron/schedule ativado: **NÃO** (`Schedule Trigger` permanece desligado por lógica em DEV)
- Workflow DEV `y6JFEnp7Z2IJEUe2` ativo ao final: **NÃO** (`active: false`, confirmado)
## 13. VEREDITO
**CONTINUAR SHADOW MODE.**
Justificativa: as correções deste ciclo são reais, testadas (102/102) e comprovadas com duas execuções reais (Run A e Run B) — o pipeline está mais correto (fallback nunca mais finge ser confirmado, contexto chega limpo pra IA, cache/cooldown provados por execução real, URL vem de config). Mas o resultado de negócio central continua honestamente **zero `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE`** nesta amostra real — o valor entregue hoje é reclassificação correta e mensagens com next_action específico, não "mais chamados acionáveis". Isso não é motivo para travar o projeto (não há bug pendente conhecido, nem risco à produção), mas também não é evidência suficiente para pedir validação humana como "pronto para produção": ainda não sabemos, com múltiplos dias reais de dados, se o padrão de zero-ACTIONABLE_NOW é estrutural (esta fila de TI realmente não tem chamados prontos-pra-agir-hoje sem terceiro) ou se o prompt da IA (não alterado neste ciclo, por instrução explícita) precisa de ajuste — essa é a única alavanca ainda não testada. Recomendação para o próximo ciclo: rodar mais alguns dias em shadow mode com o pipeline atual (sem mexer em score/pesos/limites) e, só then, se o padrão persistir, revisitar o prompt com justificativa baseada em múltiplas amostras reais — não em uma única execução.
@@ -0,0 +1,186 @@
# Relatório Final — Coordenador GLPI V2
Data: 2026-08-24. Branch: `feature/coordenador-glpi-v2` (19 commits, working tree limpo).
## RECOVERY
- Projeto encontrado: `D:\PROJETOS\Coordenador GLPI\Coordenador GLPI` — projeto n8n puro (sem backend tradicional), com workflow importável (`Coordenador GLPI - Chamados Abertos Slack.n8n.json`), query SQL (`sql/top_10_chamados_abertos_glpi.sql`), `docker-compose.yml` para n8n local e README.
- Git: repositório sem nenhum commit (`git status` mostrava tudo untracked). Não havia branch, não havia HEAD.
- Alterações preexistentes: nenhuma (nada estava em progresso). `.env` real preservado (gitignored, nunca commitado).
- Preservação: commitei o V1 exatamente como encontrado no disco (`1eb1f80`, "baseline import") **antes** de qualquer mudança, como ponto de reversão. Toda a evolução V2 foi feita em branch separada (`feature/coordenador-glpi-v2`), nunca em `master`.
## COMO FUNCIONAVA (V1, comprovado no código)
- Cron `0 8 * * 1-5` (+ Manual Trigger) → node `Config` (Set) com `slack_webhook_url`, `glpi_base_url`, `dias_sem_interacao_alerta`, `horas_sla_risco` → monta SQL em um Code node → executa direto no MySQL do GLPI.
- SQL calcula `score_criticidade` (SLA vencido +100, SLA em risco +45, dias sem interação +10/+25/+35, idade até +30, prioridade×8, urgência×5, impacto×5, sem técnico +25) e seleciona por **blocos fixos**: 3 SLA vencido, 3 sem interação, 2 sem técnico, 2 prioridade alta recente, complementando com ranking geral até 10.
- Exclui categorias `ADMINISTRATIVO%`, `DESENVOLVIMENTO%`, `INFRA%` (por `completename`/`name`) e chamados com `status IN (5,6)` (solucionado/fechado) ou `is_deleted`.
- Reincidência: contada via `staticData` do próprio n8n (não é banco) — conta dias seguidos que o chamado aparece, mas **não penaliza o score**, só informa.
- Mensagem final montada por código; um AI Agent (Z IA/GLM) só **revisa o texto** (clareza), nunca decide nada — há validação determinística (todos os `#id` e links precisam estar presentes, sem `motivo/ação/prazo`) com fallback para o texto sem IA se a revisão falhar.
## CAUSA TÉCNICA DO LOOP
O score é dominado por sinais que só crescem com o tempo (SLA vencido, dias sem interação, idade), e a reincidência é apenas informativa — não interrompe nem penaliza. Um chamado bloqueado por terceiro/solicitante, sem nenhuma ação possível pelo suporte, continua pontuando alto todos os dias e reocupando os blocos "SLA vencido"/"sem interação", empurrando chamados com ação real (mas sinais mais baixos) para fora do Top 10.
## ARQUITETURA IMPLEMENTADA
Pipeline em 7 estágios (seleção → filtros → memória/cache → contexto → IA → regras determinísticas → score/ranking → mensagem), implementado como um workflow n8n **novo e separado**, deployado no DEV, **inativo**:
```
[Manual Trigger / Schedule Trigger (desligado)] → Config
→ Code: Montar SQL Candidatos → MySQL: Buscar Candidatos (até CANDIDATE_POOL_SIZE=60, sem corte em blocos)
→ Code: Normalizar → Code: Montar SQL Contexto → MySQL: Buscar Contexto (followups+tarefas, 1 query UNION ALL)
→ Code: Mesclar Contexto e Calcular Hash
→ [Merge: Aguardar Memoria] ← Data Table: Buscar Memoria (ramo paralelo, dispara 1x a partir do Config)
→ Code: Decidir Necessidade de IA (cache/hash, corta em AI_MAX_CANDIDATES=30)
→ IF Precisa IA? → IF IA Ligada? → Code: Preparar Prompt → HTTP OpenAI (structured output) → Code: Validar
(senão) → Code: Fallback IA Desligada
(senão) → Code: Reusar Classificação em Cache
→ Merge: Consolidar Classificações → Code: Calcular Score
→ Code: Preparar Registro Memória (efeito colateral) → Data Table: Upsert Memória
→ Code: Selecionar Top 10 → Code: Montar Mensagem Curta → Code: Montar Digest Coordenação
→ Code: Preparar Registro de Envio (efeito colateral) → Data Table: Registrar Envio
→ [Merge: Unir Shadow] ← MySQL: Legado Top 10 (query V1 verbatim, ramo paralelo)
→ Code: Comparar Legacy x V2 → Data Table: Registrar Shadow Run
Debug - Ver Painel (webhook) → Data Table: Buscar Shadow Runs Recentes → Code: Montar Painel → Responder
```
Duas descobertas de arquitetura corrigidas durante a implementação (documentadas nos commits):
1. Nodes MySQL "irmãos" sem sincronização garantida — n8n não garante ordem relativa entre ramos que só compartilham um ancestral comum distante. Resolvido com nodes `Merge` reais como portão de sincronização (contexto, memória, shadow) em vez de depender de `$('Node').all()` sem uma aresta real garantindo execução prévia.
2. `Data Table` de leitura em massa ("pegar toda a tabela") não pode ficar inline na cadeia de 41 candidatos — ela roda por item de entrada e seu próprio output substitui os itens recebidos. Corrigido movendo a busca de memória para um ramo paralelo.
## ARQUIVOS ALTERADOS
- `logic/*.js` (10 módulos) + `*.test.js` — lógica determinística pura, testada.
- `sql/candidatos_v2_glpi.sql` — base do V1 sem corte em blocos, `LIMIT {CANDIDATE_POOL_SIZE}`.
- `sql/contexto_glpi.sql` — followups + tarefas numa query só (`UNION ALL`, marcada por `tipo`).
- `sql/top_10_chamados_abertos_glpi.sql` — **intocado** (usado verbatim no ramo shadow).
- `n8n/nodes/*.node.js` (17 arquivos) — drivers dos Code nodes, fonte de verdade.
- `n8n/workflow.template.json` + `tools/build-workflow.js` — template + script que gera o JSON final inlinando `logic/*.js` nos Code nodes.
- `n8n/deployed/coordenador-glpi-v2.n8n.json` — snapshot do que está no DEV.
- `docs/superpowers/{specs,plans,notes}/*` — spec, plano, notas de introspecção e este relatório.
- **V1 original**: nenhum arquivo alterado.
## BANCO / PERSISTÊNCIA
Duas n8n Data Tables novas no DEV (recurso nativo do n8n, não infraestrutura nova):
- `coordenador_glpi_memoria` (id `wQZSlILIApb6aErW`) — 17 colunas: `ticket_id, last_analyzed_at, last_sent_at, last_context_hash, last_meaningful_change_at, ai_classification, ai_confidence, blocker_type, waiting_for, next_action, next_review_at, times_sent, times_sent_30d, consecutive_days_sent, last_score, last_ai_reason, updated_at`.
- `coordenador_glpi_shadow_runs` (id `V74oP8TMq6BwJelw`) — `run_at, legacy_ids, v2_ids, added, removed, summary`.
Substitui o `staticData` do V1 (que o próprio README do V1 já apontava como frágil: some se o workflow for reimportado). Nenhuma tabela do GLPI foi alterada — todo acesso ao GLPI é `SELECT`.
## IA
- Provedor: OpenAI, modelo `gpt-4o-mini` (configurável no node Config).
- Credencial `OpenAI - Coordenador GLPI` (criada por mim, chave colada pelo usuário) — **não** reaproveitei nenhuma credencial de outro workflow.
- Formato: `response_format: json_schema` estrito (11 classes: `ACTIONABLE_NOW, UNASSIGNED_ACTIONABLE, WAITING_REQUESTER, WAITING_VENDOR, WAITING_APPROVAL, SCHEDULED, DEPENDENCY_BLOCKED, NEEDS_COORDINATION, PROJECT_LONG_TERM, DUPLICATE_OR_OBSOLETE, INSUFFICIENT_CONTEXT`), validado em código (`logic/classification.js`).
- Cache: hash de contexto (`logic/hash.js`) ignora campos que só mudam com o tempo; só entra em IA quem tem hash novo, sem memória, ou passou do `next_review_at`.
- Fallback: `ai_disabled`, `ai_error`, `invalid_json`, `ai_max_candidates_cap` — todos tratados como `ACTIONABLE_NOW` com `confidence: 0`, marcados `is_fallback: true`, sem bônus de acionabilidade no score (comportamento conservador, tipo V1).
- Custo controlado: nesta execução real, 41 candidatos → só 30 foram para a IA (`AI_MAX_CANDIDATES`), 11 ficaram em fallback controlado.
## REGRAS
- **Acionabilidade**: score final = baseline (sinais baratos, capado) + bônus/penalidade por classe da IA (ACTIONABLE_NOW +200, UNASSIGNED_ACTIONABLE +180, WAITING_* −150, SCHEDULED/NEEDS_COORDINATION −1000) − penalidade de cooldown. Confiança abaixo de `AI_CONFIDENCE_THRESHOLD` (0.6) corta o bônus pela metade.
- **Bloqueios**: `WAITING_REQUESTER/WAITING_VENDOR/SCHEDULED/DEPENDENCY_BLOCKED` sem mudança de contexto ficam bloqueados para reenvio; `NEEDS_COORDINATION` sempre sai do Top 10 diário.
- **Cooldown**: penalidade crescente (até -90) para quem foi enviado recentemente sem mudança de contexto; chamado crítico (SLA vencido + prioridade ≥4) quebra o cooldown.
- **Mudança significativa**: hash ignora `dias_aberto`/`dias_sem_interacao`; só muda com evento real (status, técnico, categoria, novo followup/tarefa).
- **Top 10**: fixo, com diversidade leve entre grupos (cap de 50% por grupo na primeira passada, preenchido pelos próximos por score — não bloqueia emergência real).
## MENSAGEM (exemplo real, gerado no DEV em 24/08/2026 19:25 UTC)
```
@time 10 chamados para atenção hoje — 24/08/2026
1. #38611 • Colocar etiqueta no espelho na sala 03 (Ultrassom)
SEM TÉCNICO • parado 3d
→ Revisar chamado
https://glpi.suaempresa.com.br/front/ticket.form.php?id=38611
5. #37389 • Manutenção preventiva
Gabriel Lessa • SLA vencido • parado 9d
→ Completar informação
https://glpi.suaempresa.com.br/front/ticket.form.php?id=37389
10. #38642 • Recolher nobreak do servidor da ortocentro
Wagner Silva • parado 0d
→ Revisar chamado
https://glpi.suaempresa.com.br/front/ticket.form.php?id=38642
```
(lista completa nos logs; aqui vão só 3 de 10 pra ilustrar). Achei e corrigi, logo depois desta captura, um bug de espaçamento (linha em branco quádrupla entre o cabeçalho e o primeiro chamado) — já corrigido e testado, mas não recapturei a execução só por causa disso.
Comparado à mensagem atual do V1 (que já é curta), esta é do mesmo tamanho por chamado mas sem os campos "motivo/ação/prazo" verbosos que o V1 tinha decidido remover; a real redução está em **não repetir os mesmos 4 chamados bloqueados todos os dias** (ver Shadow Mode abaixo), não no tamanho da mensagem em si — o V1 já era enxuto.
## TESTES
- `npm test`: **54/54 passando**, 10 arquivos de módulo + 1 de sanidade + 1 de build + 1 de guarda de SQL.
- Cobre os 17 cenários pedidos: acionável entra, aguardando cliente não monopoliza, aguardando fornecedor não repete diariamente, agendado não entra antes da data, sem técnico + acionável prioriza, cooldown penaliza reenvio sem mudança, mudança significativa libera reavaliação, alteração só de "dias aberto" não muda hash, administrativo continua excluído (testado direto no SQL gerado), IA indisponível aciona fallback, JSON inválido não quebra, baixa confiança recebe tratamento, contexto inalterado usa cache, `NEEDS_COORDINATION` sai do Top10 diário, Top final nunca passa de 10, mensagem final é curta, regra crítica quebra cooldown.
## SHADOW MODE (execução real, id `13599`, 24/08/2026 19:25:39–19:25:50 UTC, 11.1s)
```
LEGACY (query V1, verbatim):
#37389 #34419 #36530 #33492 #36488 #36897 #38611 #38583 #38319 #38642
V2:
#38611 #38583 #38648 #38645 #37389 #34419 #36530 #38216 #35059 #38642
Saíram na V2 (estavam no Legacy):
#33492 — motivo: INSUFFICIENT_CONTEXT (IA não achou base para next_action)
#36488 — motivo: WAITING_REQUESTER (aguardando "Patricia" sobre troca de carcaça)
#36897 — motivo: INSUFFICIENT_CONTEXT
#38319 — motivo: WAITING_VENDOR (aguardando fornecimento de baterias)
Entraram na V2 (não estavam no Legacy):
#38648, #38645 — ACTIONABLE_NOW (fallback por limite de IA nesta rodada, precisam triagem humana)
#38216 — INSUFFICIENT_CONTEXT ("Não há informações suficientes sobre a próxima ação ou bloqueios")
#35059 — INSUFFICIENT_CONTEXT ("Informações insuficientes para determinar ações concretas")
```
Isso é o mecanismo funcionando: 2 dos 4 removidos saíram porque a IA identificou, com evidência real do GLPI (apontamentos/tarefas), que dependiam de terceiro ou do solicitante — exatamente o padrão que a missão queria parar de repetir todo dia.
## OBSERVABILIDADE (números reais desta execução)
```
candidatos iniciais: 41 (de até 60 configurados; backlog real após exclusão administrativa)
memoria previa: 0 linhas (primeira execução, tabela nova)
enviados para IA: 30 (limite AI_MAX_CANDIDATES)
fallback por limite de IA (ai_max_candidates_cap): 11
--- classificacoes reais da IA (30) ---
INSUFFICIENT_CONTEXT: 20
WAITING_VENDOR: 6
WAITING_REQUESTER: 4
ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE (real, nao-fallback): 0
top final: 10 (nunca ultrapassou)
```
**Achado honesto**: nesta execução, a IA nunca classificou nenhum chamado como genuinamente `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE` com confiança real — ou o chamado tinha uma dependência clara (fornecedor/solicitante) ou faltava informação. Isso bate com a própria hipótese da missão (chamados antigos sem ação real) mas também expõe uma melhoria pendente: o campo `content` do GLPI vem com entidades HTML escapadas duas vezes (`&#60;p&#62;`), o que pode estar deixando o texto mais difícil de interpretar. Registrado como pendência, não escondido.
## DEPLOY DEV
- Workflow `[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)`, id `y6JFEnp7Z2IJEUe2`, 37 nodes, **`active: false`**.
- `n8n_validate_workflow` (profile `strict`): **0 erros**, 1 aviso menor (estilo de tratamento de erro, não bloqueante).
- Healthcheck: execução real `13599` terminou com `status: success` — prova de que o serviço está de pé e o workflow roda de ponta a ponta contra dados reais.
- Método de teste: ativei o workflow só pelo tempo de um disparo (webhook temporário adicionado e removido a cada tentativa), sempre desativando **imediatamente** depois — a `Schedule Trigger` nunca chegou a disparar de verdade (não estava nem perto de 8h no horário testado).
## PRODUÇÃO
```
Produção alterada: NÃO
Envio para grupo de produção realizado: NÃO
Escrita em chamados reais: NÃO
```
Todo acesso ao GLPI foi `SELECT`. Nenhum outro workflow do DEV (`[DEV] GTI - GLPI x WhatsApp v7`, `Enforcer - GLPI`, Klingo/ClinicAll/SysLife/ZPRO/Asaas) foi tocado. Nenhuma mensagem foi enviada a nenhum canal real — a mensagem fica só nos logs de execução e na Data Table.
## PENDÊNCIAS REAIS
1. **HTML duplamente escapado no `content` do GLPI** (`&#60;p&#62;`) pode estar prejudicando a leitura da IA — vale decodificar/limpar em `logic/context.js` antes de mandar pra IA.
2. **`blocker_type` às vezes vem como string `"null"`** em vez de `null` de verdade (comportamento do modelo, não do meu código) — inofensivo hoje, mas vale normalizar.
3. **Digest de coordenação** (`Code - Montar Digest Coordenacao`) ainda não foi validado com um caso real de `NEEDS_COORDINATION` (nenhum chamado caiu nessa classe nesta execução) — a lógica está coberta por teste unitário, mas não por execução real.
4. **Cron desligado por design** — a ativação para rodar automaticamente às 8h é uma decisão do usuário, não foi feita (e não deveria ser feita sem aprovação explícita).
5. **Duas Data Tables de terceiros** (`dev_glpi_v2_insights_memory`, `dev_glpi_v2_run_history`) apareceram no DEV no meio desta sessão, de origem que não identifiquei — vale confirmar com quem estiver por trás se é trabalho duplicado.
## VEREDITO
A V2 está **pronta para continuar em shadow mode** — validada estruturalmente (`n8n_validate_workflow`), testada unitariamente (54/54) e comprovada com uma execução real de ponta a ponta contra o banco de produção do GLPI (somente leitura) e a API real da OpenAI, produzindo um diff Legacy×V2 defensável. Antes de virar candidata a substituir o envio real, sugiro: (a) rodar mais algumas vezes em dias diferentes para ver o comportamento do cache/cooldown ao longo do tempo (hoje só temos a primeira execução, sem histórico), (b) resolver a pendência 1 (HTML escapado) e comparar se isso muda a proporção de `INSUFFICIENT_CONTEXT`, (c) revisar manualmente as 10 mensagens junto com o time de suporte antes de decidir mudar o canal real.
@@ -0,0 +1,49 @@
# Introspecção de schema GLPI — Task 14
Status: **concluído**. Credencial `GLPI` (mySql, id `dsVj1nmOIXRvix9n`) criada pelo usuário
às 2026-08-24 18:51 UTC. Rodei `SHOW COLUMNS` real contra o banco (via workflow temporário
de introspecção, deletado depois) e confirmei tudo abaixo.
## O que foi confirmado (real, via `SHOW COLUMNS` contra o banco)
- `glpi_tickettasks.state`: existe exatamente como assumido em
`sql/contexto_glpi.sql` (`int`, default `1`). Nenhum ajuste necessário.
- `glpi_itilfollowups`: `itemtype`, `items_id`, `date`, `content`,
`is_private`, `date_mod`, `date_creation` — todos confirmados, batendo
com o que a query V1 (já em produção) sempre assumiu.
- `sql/candidatos_v2_glpi.sql` rodada de verdade (LIMIT 5) contra o banco
real: retornou linhas plausíveis, incluindo os chamados #34419, #36530 e
#37945 — os mesmos números que o usuário já tinha usado como exemplo na
missão original, confirmando que é o mesmo GLPI.
- Execução completa do workflow V2 (id de execução `13599`,
2026-08-24 19:25 UTC): 41 candidatos reais, contexto (followups/tarefas)
buscado com sucesso, 30 chamados avaliados pela IA (limite
`AI_MAX_CANDIDATES`), memória gravada com 41 linhas reais. Ver relatório
final para os números completos.
## Achado durante o deploy (não é sobre schema do GLPI, mas registrado aqui)
Ao criar o workflow via API sem a credencial MySQL existir ainda, o n8n
**resolveu sozinho por tipo** e vinculou os 3 nodes MySQL à única credencial
`mySql` existente no ambiente (`MySQL account - Gerar e Enviar Chave`, do
sistema Asaas/cobrança — nada a ver com GLPI). Removi essa vinculação
(`credentials: null`) nos 3 nodes MySQL do workflow `y6JFEnp7Z2IJEUe2` e no
template local, para não deixar o workflow silenciosamente "configurado"
apontando pro banco errado. **Resolvido**: quando o usuário criou a credencial
`GLPI` (id `dsVj1nmOIXRvix9n`), reapontei os 3 nodes MySQL para ela via
`n8n_update_partial_workflow`.
Nodes afetados: `MySQL - Buscar Candidatos`, `MySQL - Legado Top 10 (Shadow)`,
`MySQL - Buscar Contexto`.
## Achado adicional (Data Tables de terceiros)
No mesmo instante em que eu investigava este ambiente, apareceram duas Data
Tables novas no DEV que eu não criei: `dev_glpi_v2_insights_memory` e
`dev_glpi_v2_run_history` (criadas ~18:37, no meio desta sessão). O schema
delas não bate com o desenho deste projeto (parecem de outra tentativa —
colunas tipo `fingerprint`/`insight_type`/`subject_key` e
`tipo_periodo`/`history_bucket`). Não toquei nelas. Criei as minhas com nomes
próprios (`coordenador_glpi_memoria`, `coordenador_glpi_shadow_runs`) para não
colidir. Vale confirmar com quem estiver por trás dessas tabelas se é
trabalho duplicado.
@@ -0,0 +1,79 @@
# Coordenador GLPI V2 — Correção Final (25/08/2026)
## CORREÇÕES
1. **Bug real, causa raiz do "→ Validar e executar"**: `logic/message.js` tinha um rótulo fixo hardcoded pra `ACTIONABLE_NOW` que sempre vencia o `next_action` real da IA. Removido — agora mostra a ação real e específica.
2. **Blacklist de frases genéricas expandida** (`validar e executar`, `validar chamado`, `prosseguir atendimento`, `executar chamado`, `tratar chamado`, `analisar chamado`) — `AI_CLASSIFIER_VERSION` 3→4 pra forçar reclassificação.
3. **`decision_reason` distinguível**: rejeição por next_action genérico agora usa `generic_or_unsupported_next_action` em vez de cair em `invalid_json`.
4. **`FORCE_SEND` implementado**: execução manual nunca envia de verdade mesmo com `SEND_ENABLED=true`, a menos que `FORCE_SEND` também esteja ligado.
5. **Bug real e crítico, causa raiz do envio duplicado**: `logic/diversity.js` desempatava chamados com score igual pela ordem de chegada dos itens — que o n8n não garante entre branches paralelos (cache/IA/fallback se juntam antes do Top10). Duas execuções seguidas com os mesmos dados geravam Top10 diferentes → hash diferente → duas mensagens reais distintas no mesmo dia. Corrigido com desempate determinístico por `ticket.id` — comprovado com 2 execuções reais consecutivas produzindo o **mesmo hash exato**.
6. **Rodapé "Automated with this n8n workflow" removido** (`otherOptions.includeLinkToWorkflow: false`) — confirmado ausente na mensagem real enviada.
7. `\n` literal e link corrompido reportados: **investigado, não reproduzido em nenhuma execução real**. As duas execuções que geraram aquele Top10 (#38413 etc.) tinham `shouldSend:false` — nenhuma mensagem chegou a ser enviada. O texto veio do painel de dados bruto do n8n (JSON), que exibe `\n` como dois caracteres — não é um bug de envio. Adicionei testes de formatação mesmo assim (blindagem).
## TESTES
```
171/171 passando
n8n_validate_workflow: limpo, exceto 3 falsos positivos pré-existentes e documentados
(MySQL query sem prefixo =, contradito por múltiplas execuções reais bem-sucedidas)
```
## TOP REAL (execução real, pós-todas-as-correções, comprovadamente determinístico)
| #ID | classe | display_action | score |
|---|---|---|---|
| #33522 | ACTIONABLE_NOW | Realizar a migração dos e-mails | 250 |
| #36488 | ACTIONABLE_NOW | Contatar Patricia sobre troca de carcaça | 250 |
| #36897 | ACTIONABLE_NOW | Comprar um SSD | 250 |
| #36925 | ACTIONABLE_NOW | Realizar configuração do G-Chat | 250 |
| #37073 | ACTIONABLE_NOW | Testar impressão na impressora | 250 |
| #37691 | ACTIONABLE_NOW | Ver o que vai precisar para a migração | 220 |
| #38654 | ACTIONABLE_NOW | Realizar instalação dos tablets | 216.4 |
| #38657 | ACTIONABLE_NOW | Realizar analise do nobreak | 213.6 |
| #38635 | ACTIONABLE_NOW | Verificar configuração do computador | 211.6 |
| #38644 | ACTIONABLE_NOW | Continuar avaliação e monitoramento da conexão | 211.6 |
Nenhum `Validar e executar`. Nenhum `decisionReason` de override crítico necessário — todos venceram por mérito real.
## #62 / #75
Ambos são chamados **reais e legítimos**, não resíduo histórico:
- **#75** (`#1495 - Criar regra de proteção...`, aberto em 2020): tem tarefa aberta real de 29/07/2026 ("Realizar levantamento de quais clientes tem microtik") — evidência direta e recente.
- **#62** (`#8819 - Falha em um dos discos...`, aberto em 2024): disco com bad block confirmado, tarefa reaberta em 29/07/2026 ("Da inicio a analise"), `sla_vencido: true`.
IDs baixos refletem apenas quando foram criados no GLPI — a atividade recente real é o que justifica a presença deles. Nenhum filtro por ID/idade foi implementado (sem evidência objetiva de que sejam lixo importado).
## SLACK
```
quebra de linha real: OK (confirmado em 2 envios reais bem-sucedidos)
URLs: OK (puras, sem markdown [url](url), Slack auto-linka)
rodapé n8n removido: SIM
segunda tentativa duplicada bloqueada: SIM (comprovado -- mesmo hash em 2 execuções consecutivas após o fix de determinismo)
```
Dois envios reais aconteceram durante a investigação (autorização já em vigor), ambos corretamente formatados. Nenhum terceiro envio foi feito após encontrar o bug de determinismo — voltei pro estado seguro imediatamente.
## SCHEDULE
```
segunda-sexta: SIM (0 8 * * 1-5)
08:00: SIM
timezone: America/Sao_Paulo
trigger conectado: SIM
```
## PRODUÇÃO
```
workflow ativo: NÃO
envio habilitado (SEND_ENABLED): NÃO
FORCE_SEND: NÃO
GLPI escrita: NÃO
```
## VEREDITO
**PRONTO PARA USO**
Todos os bugs reais encontrados (rótulo fixo, determinismo do Top10, columns faltando, alwaysOutputData, escopos OAuth) foram corrigidos e comprovados com execução real — incluindo o bug de duplicidade, que era mais sério do que o relatado (não era a guarda de idempotência falhando, era o próprio conteúdo sendo instável entre execuções). Ativação (`SEND_ENABLED=true` + `active=true`) é a única ação restante, e é sua para decidir quando.
@@ -0,0 +1,112 @@
# Coordenador GLPI V2 — Finalização para Produção (25/08/2026)
## CLASSIFICADOR
**Antes** (execução 14259, prompt v2 sem versionamento):
```
ACTIONABLE_NOW: 0
UNASSIGNED_ACTIONABLE: 0
WAITING_*: 14 (WAITING_VENDOR:8, WAITING_REQUESTER:6)
INSUFFICIENT_CONTEXT: 16
AI_FALLBACK: 15
```
**Depois** (execução 14404, prompt v3, todos os 45 candidatos analisados):
```
ACTIONABLE_NOW: 29
UNASSIGNED_ACTIONABLE: 11
WAITING_*: 3 (WAITING_VENDOR:1, WAITING_REQUESTER:2)
INSUFFICIENT_CONTEXT: 0
AI_FALLBACK: 1 (invalid_json, 1 chamado — falha real de parsing, não cap artificial)
SCHEDULED: 1
```
`ai_requested: 45/45` (sem cap artificial), `critical_override_count: 0` (não precisou mais de exceção — os acionáveis venceram por mérito).
## CASOS CRÍTICOS
| ID | Classe anterior | Classe nova | next_action | Entrou no Top10? |
|---|---|---|---|---|
| #34419 | WAITING_VENDOR (waiting_for: Rafael Brito — **erro**, técnico interno) | **ACTIONABLE_NOW** | Solicitar Rafael para fazer a migração de E-mails | SIM (#2) |
| #37691 | WAITING_REQUESTER (sem evidência de contato) | **ACTIONABLE_NOW** | Ver o que vai precisar para a migração | SIM (#7) |
| #37389 | INSUFFICIENT_CONTEXT | **ACTIONABLE_NOW** | Realizar inventário das máquinas | SIM (#1) |
| #36530 | INSUFFICIENT_CONTEXT | UNASSIGNED_ACTIONABLE | Contatar cliente sobre e-mail básico | não (fora do corte numérico) |
| #33492 | INSUFFICIENT_CONTEXT | UNASSIGNED_ACTIONABLE | Atribuir técnico para instalação | não |
| #36488 | WAITING_VENDOR (waiting_for: Patricia) | ACTIONABLE_NOW | Contatar Patricia sobre troca de carcaça | não |
| #38319 | WAITING_VENDOR (baterias) | WAITING_VENDOR (**correto** — fornecedor externo real) | Aguardar fornecimento de baterias | não (bloqueado corretamente) |
`#34419` e `#37691` são a prova direta de que a guarda técnico-interno-≠-fornecedor e a distinção contatar-vs-aguardar funcionaram em dados reais. `#38319` prova que a IA não passou a evitar `WAITING_VENDOR` cegamente — continua usando a classe quando a dependência é genuinamente externa.
## TOP 10 FINAL (inspecionado criticamente, item a item — ver seção completa na conversa)
9 dos 10 itens têm evidência textual direta e específica sustentando `ACTIONABLE_NOW`/`UNASSIGNED_ACTIONABLE`. Um item (#37945) é limítrofe — a evidência cita "vai depender de André" e "André já está analisando", o que é mais "acompanhar andamento interno" do que uma ação nova — mas não é um erro de técnico-vs-fornecedor (André é interno) nem uma ação inventada; é um caso legítimo de julgamento, não um bug de padrão a corrigir.
## SLACK
```
node envio Slack: SIM (n8n-nodes-base.slack nativo, credencial slackApi — não webhook cru)
credencial: "Slack account" (já existente na instância, não criada nesta sessão)
destino reutilizado da V1: NÃO — V1 nunca foi de fato implantada nesta instância
(seu Config tem o valor literal placeholder "SEU/WEBHOOK/AQUI", nunca preenchido).
Reutilizado em vez disso o único mecanismo Slack REAL e credenciado desta instância
(workflow "Notificacao de Erros Globais", canal $vars.SLACK_CHANNEL_TECNICO).
SEND_ENABLED: false (revertido após o teste real)
proteção duplicidade: SIM (13/13 testes de unidade; teste end-to-end de sucesso real
pendente da resolução do canal, ver TESTE REAL abaixo)
```
**TESTE REAL** (autorizado explicitamente por você): o node Slack disparou corretamente
e recebeu uma resposta REAL da API do Slack — erro `invalid_arguments`. Isso confirma que
o mecanismo (credencial, node, pipeline) funciona ponta a ponta até a chamada de API; o
problema está no valor de `$vars.SLACK_CHANNEL_TECNICO`, que provavelmente não resolve
para um canal válido nesta instância — não tenho ferramenta para listar/inspecionar
n8n Instance Variables para confirmar. No mesmo teste encontrei e corrigi um bug real e
separado: os dois nodes novos de registro de envio (sucesso/falha) não tinham `columns`
configurado, o que quebrava o próprio node de insert do n8n.
## SCHEDULE
```
segunda-sexta: SIM (cron 0 8 * * 1-5)
horário: 08:00
timezone: America/Sao_Paulo (explícito em settings.timezone; mesma timezone já usada
no cálculo de dateStr em todo o projeto, não é suposição nova)
trigger conectado: SIM (Schedule Trigger -> Config, testado via workflow gerado)
```
## TESTES
```
158/158 passando
n8n_validate_workflow: limpo, exceto 3 falsos positivos pré-existentes e documentados
(MySQL query sem prefixo =, contradito por múltiplas execuções reais bem-sucedidas
ao longo de todo o projeto)
```
## PRODUÇÃO
```
V1 ativa: NÃO (nunca esteve ativa nesta instância — nada a desativar)
V2 ativa: NÃO (active=false, DEV)
SEND_ENABLED: false
Schedule: não ativado
```
**Bloqueio externo real**: preciso que você confirme/corrija o destino real do Slack —
ou (a) confirme que a n8n Variable `SLACK_CHANNEL_TECNICO` existe e está configurada com
um channel ID válido nesta instância (`devn8n.gtigrupo.com.br`), ou (b) me informe o
ID/nome do canal Slack correto para eu configurar diretamente no node, sem depender
dessa Variable. Assim que isso for resolvido, o teste real de envio e a ativação podem
ser concluídos na mesma sessão — não há nenhum outro item tecnicamente pendente.
## VEREDITO
**PRONTO PARA ATIVAÇÃO — BLOQUEADO APENAS POR ACESSO**
Especificamente: acesso/confirmação da configuração do canal Slack de destino — não
acesso técnico ao n8n (que já tenho pleno) nem bug de código conhecido. Todos os demais
gates da missão passaram: classificador corrigido e validado com dados reais, `next_action`
concreto, técnico interno nunca mais confundido com fornecedor, cache versionado, todos os
candidatos elegíveis analisados, `WAITING_*` nunca mais entra no Top10 independente de
`contextChanged`, pipeline Slack real construído e testado (inclusive um teste real de
envio, que revelou e já teve corrigido um bug real), Schedule Trigger conectado e correto,
GLPI seguindo somente leitura, 158/158 testes, V1 preservada.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
# Coordenador GLPI V2 — Seleção acionável por IA + regras determinísticas
Status: aprovado pelo usuário em 2026-08-24. Implementação em DEV, shadow mode.
## Contexto (Etapa 0 — o que existe hoje)
Projeto 100% n8n, sem backend tradicional:
- `Coordenador GLPI - Chamados Abertos Slack.n8n.json`: workflow importável (V1).
- `sql/top_10_chamados_abertos_glpi.sql`: query base (duplicada dentro do node `Code - Montar SQL Top 10`).
- `docker-compose.yml` + `.env(.example)`: n8n local (Docker Desktop indisponível neste ambiente de execução; não foi possível subir localmente).
- `tools/`: pasta vazia, reservada — reaproveitada nesta V2 para o script de build do workflow.
- Repositório git local sem nenhum commit (tudo untracked). Nada a preservar de histórico.
### Lógica V1 (comprovada, reduziu backlog de ~167 para ~90)
- Trigger: `Schedule Trigger` cron `0 8 * * 1-5` (+ `Manual Trigger` para teste).
- Node `Config - Coordenador GLPI` (Set): `slack_webhook_url`, `glpi_base_url`, `dias_sem_interacao_alerta` (default 2), `horas_sla_risco` (default 8).
- Node `Code - Montar SQL Top 10` monta a query com esses parâmetros e executa via node MySQL (credencial `MySQL GLPI`, nunca configurada em nenhum ambiente acessível a mim).
- SQL: filtra `is_deleted=false AND status NOT IN (5,6)` e exclui categorias `ADMINISTRATIVO%`, `DESENVOLVIMENTO%`, `INFRA%` (via `completename`/`name`). Calcula `dias_aberto`, `dias_sem_interacao` (maior data entre followups públicos, tarefas, `date_mod`, `date` — nunca negativo), `sla_vencido`, `sla_em_risco`, `sem_tecnico`. Score: SLA vencido +100, SLA em risco +45, sem interação (+35/+25/+10 por faixa), idade até +30, prioridade×8, urgência×5, impacto×5, sem técnico +25. Seleção final em blocos (3 SLA vencido, 3 sem interação, 2 sem técnico, 2 prioridade alta recente, complemento pelo ranking geral), sem repetir chamado entre blocos, `LIMIT 10`.
- Node `Code - Reincidencia e mensagem curta`: reincidência via `$getWorkflowStaticData('global')` (chamado apareceu N dias seguidos: 1 normal, 2 reincidente, 3+ escalar). Monta mensagem determinística e um `prompt_ia`.
- `AI Agent - Revisar mensagem` (LangChain agent + `Z IA Chat Model`, GLM-4.5-flash via endpoint compatível OpenAI): só revisa clareza/reduz texto, proibido inventar fato/motivo/ação/prazo.
- `Code - Preparar mensagem Slack`: valida que a saída da IA preserva todos os `#id` e links e não contém campos proibidos (`motivo/ação/prazo`); se falhar, usa o texto determinístico puro (fallback já existente e funcional).
- `HTTP - Slack enviar mensagem`: POST no webhook configurado no Config node.
### Causa técnica do "loop" (chamados repetindo o Top 10 sem ação real)
O score é dominado por sinais que só crescem com o tempo (`dias_sem_interacao`, SLA vencido, idade) e a reincidência do V1 é só um contador informativo — não penalizada no score nem interrompida por cooldown. Um chamado com SLA vencido há semanas e sem ação prática do suporte continua pontuando alto todos os dias e reentra nos blocos "SLA vencido"/"sem interação", empurrando candidatos com ação real (mas sinais mais baixos) para fora do Top 10.
### Ambiente DEV confirmado
Instância n8n real e acessível via MCP: `https://devn8n.gtigrupo.com.br` (73 workflows). Não existe lá nenhum workflow do "Coordenador GLPI - Top 10"; o que existe de GLPI é um chatbot de abertura de chamados via WhatsApp (`[DEV] GTI - GLPI x WhatsApp (Z-PRO) v7`, ativo, 52 nodes) e um sub-workflow arquivado de lock/unlock de entidade (`Enforcer - GLPI`). Ambos **fora de escopo** — não serão alterados. Não há credencial MySQL do GLPI nem de IA "Z IA" nesse ambiente.
Decisões do usuário (2026-08-24):
- Credencial MySQL: nome `MySQL GLPI - Coordenador`, criada pelo usuário diretamente no n8n DEV.
- Credencial IA: nova credencial `OpenAI - Coordenador GLPI` (id `WUGriAn5FgCiLza2`, tipo `openAiApi`) criada por mim com chave-placeholder óbvia; o usuário substitui o valor real direto na UI do n8n. Provedor: OpenAI (GPT), não Z IA, não reaproveitar credencial existente de outro workflow.
- Nenhum envio real (Slack/WhatsApp) sem destino seguro definido — por padrão, shadow mode só grava/loga, nunca publica externamente.
## Objetivo da V2
Trocar "quais chamados são mais problemáticos" por "quais chamados têm ação concreta que o suporte consegue executar agora", mantendo Top 10, mensagem curta (mais curta que hoje) e todas as regras já comprovadas (exclusão administrativa, SLA, prioridade).
## Arquitetura
Workflow novo e separado no n8n DEV — **V1 permanece intocado** (local e não importado em nenhum ambiente acessível):
`[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)`, inativo por padrão, disparado por Manual Trigger durante a validação.
Pipeline (7 estágios):
1. **Seleção inicial**: SQL reaproveita a base V1 (mesmas tabelas, mesmo `dias_sem_interacao`, mesma exclusão administrativa) mas remove o corte de blocos/`LIMIT 10` — devolve até `CANDIDATE_POOL_SIZE` (padrão configurável, começar em 60) candidatos ordenados pelo `score_criticidade` barato atual. Este número é o tamanho do funil, não o volume que vai para a IA.
2. **Filtros determinísticos**: elegibilidade por status, administrativo (já no SQL), duplicidade.
3. **Memória/cache**: n8n Data Table `coordenador_glpi_memoria` (não `staticData` — resolve a fragilidade documentada no README do V1 de perder histórico ao reimportar o workflow). Colunas: `ticket_id, last_analyzed_at, last_sent_at, last_context_hash, last_meaningful_change_at, ai_classification, ai_confidence, blocker_type, waiting_for, next_action, next_review_at, times_sent, times_sent_30d, consecutive_days_sent, last_score, last_ai_reason, updated_at`.
4. **Hash de contexto**: função JS pura (sem `crypto` do Node — mais portável em Code node) sobre campos que só mudam por evento real (status, técnico, grupo, último followup/tarefa por id+data, solução, pendência, aprovação, vínculos). Nunca inclui `dias_aberto`/`dias_sem_interacao`.
5. **Coleta de contexto**: só para candidatos sem cache válido (hash mudou ou passou `next_review_at`). Usa tabelas já confirmadas (`glpi_itilfollowups`, `glpi_tickettasks`, `glpi_tickets_users`, `glpi_users`, `glpi_entities`, `glpi_itilcategories`). Tabelas adicionais candidatas (soluções, aprovações, chamados vinculados) só entram depois de confirmadas por introspecção real no banco (`SHOW TABLES`/`DESCRIBE`) quando a credencial MySQL estiver disponível — nunca assumidas de antemão. Cada fonte opcional é isolada com fallback gracioso (se a tabela não existir/,divergir, o pipeline continua sem ela e registra isso no log).
6. **Classificação IA**: só os candidatos sem cache válido chegam aqui (estágio 4), até o teto `AI_MAX_CANDIDATES` (padrão 30 — proteção de custo/latência por execução; se sobrarem mais candidatos elegíveis que o teto, prioriza por `score_criticidade` e deixa o resto para a próxima execução via `next_review_at`). HTTP direto à API da OpenAI (Chat Completions com `response_format: json_schema`) — mesmo padrão já usado no workflow `[DEV] GTI - GLPI x WhatsApp v7` deste n8n (não o `AI Agent`/LangChain do V1, que só polia texto). Classes: `ACTIONABLE_NOW, UNASSIGNED_ACTIONABLE, WAITING_REQUESTER, WAITING_VENDOR, WAITING_APPROVAL, SCHEDULED, DEPENDENCY_BLOCKED, NEEDS_COORDINATION, PROJECT_LONG_TERM, DUPLICATE_OR_OBSOLETE, INSUFFICIENT_CONTEXT`. Schema conforme o documento do usuário (`classification, actionable_today, confidence, blocker_type, waiting_for, next_action, suggested_action_type, business_risk, reason, evidence`). Validação estrita em código; JSON inválido/timeout/erro → fallback determinístico (log explícito de fallback, memória não corrompida).
7. **Regras determinísticas + score + ranking**: acionabilidade IA como fator dominante; SLA/idade continuam pesando mas não dominam sozinhos; penalidade forte para bloqueio externo/`WAITING_*`/`NEEDS_COORDINATION`/reenvio sem mudança (cooldown crescente); exceção permite prioridade crítica furar cooldown. Diversidade leve entre grupos conceituais. Top 10 fixo (não mexe no volume nesta fase).
8. **Mensagem final**: formato curto (exemplo no design aprovado), sem score/confiança/justificativa longa. Envio real desligado por padrão (`SHADOW_MODE=true`): grava em Data Table/log, não publica em canal nenhum até haver destino seguro confirmado pelo usuário.
9. **Bloqueados/coordenação**: saída separada, só com novidades/mudanças desde a última vez (não repete lista idêntica).
10. **`INSUFFICIENT_CONTEXT`**: telemetria própria (quais chamados, por quê, o que falta).
## Configuração (env vars no estilo já usado no n8n desta org, padrão `env(K, default)`)
`AI_ENABLED, AI_PROVIDER=openai, AI_MODEL, CANDIDATE_POOL_SIZE=60, AI_MAX_CANDIDATES=30, TOP_N=10, COOLDOWN_DAYS, AI_CONFIDENCE_THRESHOLD, AI_MAX_CONTEXT_CHARS, DEV_MODE=true, SHADOW_MODE=true`.
## Testes
Sem framework de testes no projeto hoje. Lógica determinística pura (hash, cooldown, score, elegibilidade, diversidade, formatação de mensagem) extraída para `logic/*.js`, testada com o runner nativo do Node (`node:test`, zero dependência nova). Script em `tools/` monta o JSON do workflow a partir desses módulos (fonte única, sem divergência entre o testado e o que roda no n8n). Cobrir os 17 casos do documento do usuário (seção 22).
## Shadow mode
Mesma execução manual roda a query legado (V1, sem corte) e a lógica nova lado a lado, grava os dois rankings e o diff (quem entrou/saiu e por quê) em Data Table + endpoint de debug (mesmo padrão `Debug - Ver Log`/`Debug - Montar Painel` já usado no workflow de WhatsApp desse n8n).
## Fora de escopo / não fazer
Produção; qualquer workflow do n8n DEV não relacionado a este projeto (WhatsApp GLPI, Enforcer, Klingo, ClinicAll, SysLife, ZPRO, Asaas etc.); reduzir o Top 10 para menos de 10; aumentar o tamanho da mensagem; deixar a IA decidir sozinha; reprocessar todo dia sem necessidade; escrever diretamente em tabelas do GLPI.
+17
View File
@@ -0,0 +1,17 @@
function needsReanalysis({ memory, currentHash, now }) {
if (!memory) return { needsAi: true, reason: 'no_cache' };
if (memory.last_context_hash !== currentHash) return { needsAi: true, reason: 'context_changed' };
if (memory.next_review_at != null && now >= memory.next_review_at) return { needsAi: true, reason: 'review_due' };
return { needsAi: false, reason: 'cache_valid' };
}
// Chave de cache composta (conteudo do chamado + versao do classificador). Guardada no MESMO
// campo last_context_hash que ja existia -- sem migracao de schema na Data Table. Uma classe
// derivada e valida SE E SOMENTE SE contextHash bate E classifierVersion bate: bump de versao
// (logic/classification.js -> AI_CLASSIFIER_VERSION) forca cache miss em todo chamado, sem
// precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
function buildCacheKey(contextHash, classifierVersion) {
return `${contextHash}:v${classifierVersion}`;
}
module.exports = { needsReanalysis, buildCacheKey };
+52
View File
@@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { needsReanalysis, buildCacheKey } = require('./cache');
test('sem memoria previa, sempre precisa de IA', () => {
const result = needsReanalysis({ memory: null, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'no_cache');
});
test('hash igual e sem next_review_at vencido usa cache', () => {
const memory = { last_context_hash: 'abc', next_review_at: null };
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, false);
assert.equal(result.reason, 'cache_valid');
});
test('hash diferente forca reavaliacao (mudanca significativa)', () => {
const memory = { last_context_hash: 'abc', next_review_at: null };
const result = needsReanalysis({ memory, currentHash: 'xyz', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'context_changed');
});
test('next_review_at vencido forca reavaliacao mesmo com hash igual', () => {
const memory = { last_context_hash: 'abc', next_review_at: 500 };
const result = needsReanalysis({ memory, currentHash: 'abc', now: 1000 });
assert.equal(result.needsAi, true);
assert.equal(result.reason, 'review_due');
});
test('buildCacheKey combina hash de conteudo e versao do classificador', () => {
assert.equal(buildCacheKey('abc123', 3), 'abc123:v3');
assert.equal(buildCacheKey('abc123', 4), 'abc123:v4');
});
test('mudanca de AI_CLASSIFIER_VERSION invalida o cache mesmo com conteudo identico -- missao secao 12', () => {
const contentHash = 'abc123';
const oldCacheKey = buildCacheKey(contentHash, 2);
const newCacheKey = buildCacheKey(contentHash, 3);
const memory = { last_context_hash: oldCacheKey, next_review_at: null };
const result = needsReanalysis({ memory, currentHash: newCacheKey, now: 1000 });
assert.equal(result.needsAi, true, 'bump de versao deve forcar reclassificacao mesmo com o mesmo conteudo');
});
test('mesma versao + mesmo hash de conteudo usa cache normalmente -- missao secao 12', () => {
const cacheKey = buildCacheKey('abc123', 3);
const memory = { last_context_hash: cacheKey, next_review_at: null };
const result = needsReanalysis({ memory, currentHash: cacheKey, now: 1000 });
assert.equal(result.needsAi, false);
assert.equal(result.reason, 'cache_valid');
});
+123
View File
@@ -0,0 +1,123 @@
const VALID_CLASSIFICATIONS = [
'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',
'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',
'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',
];
// Versao do prompt/classificador. Bump manual sempre que o system prompt ou as regras de
// validacao mudarem de forma que mudaria o julgamento da IA -- forca cache miss em TODO
// chamado (ver logic/cache.js -> buildCacheKey), sem precisar apagar a Data Table na mao.
// v4: lista de frases genericas rejeitadas expandida ("Validar e executar" e variantes --
// missao "correcao final", secao 5). Bump forca reclassificacao de qualquer candidato
// cacheado sob a v3 cujo next_action so passava porque a v3 nao conhecia essas frases.
const AI_CLASSIFIER_VERSION = 4;
// O modelo, apesar do schema permitir null de verdade em blocker_type/waiting_for, as vezes
// devolve a STRING "null" (ou variantes equivalentes) em vez do valor JSON null. Normaliza
// aqui, na camada de validacao -- unico lugar que toca a resposta crua da IA.
const NULL_LIKE_STRINGS = new Set(['null', 'none', 'n/a', 'na', '']);
function normalizeNullableString(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (NULL_LIKE_STRINGS.has(trimmed.toLowerCase())) return null;
return trimmed;
}
// next_action generico nao prova acionabilidade nenhuma -- ver missao "finalizacao para
// producao", secao 9. So se aplica a ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE: nessas duas
// classes o next_action E a evidencia de que existe um proximo passo concreto sob controle
// do suporte; nas demais classes o campo descreve o que se espera de terceiros, entao uma
// frase curta ("aguardar resposta") e legitima ali.
const ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION = new Set(['ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE']);
// Lista expandida na missao "correcao final" (secao 5) -- "Validar e executar" era o rotulo
// fixo que a mensagem real mostrou pra quase todo ACTIONABLE_NOW (ver logic/message.js).
const GENERIC_NEXT_ACTION_PHRASES = new Set([
'revisar chamado', 'revisar o chamado', 'dar andamento', 'dar andamento no chamado',
'analisar situacao', 'analisar a situacao', 'analisar chamado', 'verificar chamado', 'verificar o chamado',
'acompanhar', 'acompanhar chamado', 'acompanhar o chamado',
'validar e executar', 'validar chamado', 'prosseguir atendimento', 'executar chamado', 'tratar chamado',
]);
function normalizeForComparison(text) {
return String(text || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // remove acentos (combining marks) p/ comparar "situação" == "situacao"
.replace(/[.!]+$/g, '');
}
function isGenericNextAction(nextAction) {
return GENERIC_NEXT_ACTION_PHRASES.has(normalizeForComparison(nextAction));
}
// Guarda deterministica: tecnico interno (aparece no campo TECNICO do proprio chamado) nunca
// pode ser tratado como fornecedor externo (WAITING_VENDOR). Ver missao "finalizacao para
// producao", secao 6. Isso nao depende do prompt se comportar bem -- e verificado aqui.
function isWaitingForInternalTechnician(waitingFor, ticket) {
if (!waitingFor || !ticket || !ticket.tecnico_atribuido) return false;
const tecnico = normalizeForComparison(ticket.tecnico_atribuido);
const alvo = normalizeForComparison(waitingFor);
if (!alvo || tecnico === 'sem tecnico atribuido') return false;
return tecnico.includes(alvo);
}
function validateAiClassification(raw, ticket) {
const errors = [];
if (!raw || typeof raw !== 'object') {
return { valid: false, data: null, errors: ['resposta nao e um objeto JSON'] };
}
if (!VALID_CLASSIFICATIONS.includes(raw.classification)) {
errors.push(`classification invalida: ${raw.classification}`);
}
if (typeof raw.actionable_today !== 'boolean') errors.push('actionable_today deve ser boolean');
const confidence = Number(raw.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) errors.push('confidence deve estar entre 0 e 1');
if (typeof raw.next_action !== 'string' || raw.next_action.trim().length === 0 || raw.next_action.length > 200) {
errors.push('next_action invalido (vazio ou muito longo)');
} else if (
ACTIONABLE_CLASSES_REQUIRING_CONCRETE_NEXT_ACTION.has(raw.classification)
&& isGenericNextAction(raw.next_action)
) {
errors.push(`next_action generico demais para ${raw.classification}: "${raw.next_action}"`);
}
if (raw.evidence != null && !Array.isArray(raw.evidence)) errors.push('evidence deve ser array');
if (typeof raw.reason !== 'string') errors.push('reason deve ser string');
const waitingFor = normalizeNullableString(raw.waiting_for);
if (raw.classification === 'WAITING_VENDOR' && isWaitingForInternalTechnician(waitingFor, ticket)) {
errors.push(`waiting_for ("${waitingFor}") corresponde a tecnico interno do chamado, nao pode ser WAITING_VENDOR`);
}
if (errors.length) return { valid: false, data: null, errors };
return {
valid: true,
errors: [],
data: {
classification: raw.classification,
actionable_today: raw.actionable_today,
confidence,
blocker_type: normalizeNullableString(raw.blocker_type),
waiting_for: waitingFor,
next_action: raw.next_action,
suggested_action_type: raw.suggested_action_type ?? 'technical_action',
business_risk: raw.business_risk ?? 'medium',
reason: raw.reason,
evidence: Array.isArray(raw.evidence) ? raw.evidence.slice(0, 5) : [],
is_fallback: false,
fallback_reason: null,
classifier_version: AI_CLASSIFIER_VERSION,
},
};
}
module.exports = {
VALID_CLASSIFICATIONS,
AI_CLASSIFIER_VERSION,
validateAiClassification,
normalizeNullableString,
isGenericNextAction,
isWaitingForInternalTechnician,
};
+147
View File
@@ -0,0 +1,147 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { validateAiClassification, VALID_CLASSIFICATIONS, AI_CLASSIFIER_VERSION } = require('./classification');
function validRaw(overrides) {
return Object.assign({
classification: 'ACTIONABLE_NOW',
actionable_today: true,
confidence: 0.91,
blocker_type: null,
waiting_for: null,
next_action: 'Validar equipamento e registrar resultado.',
suggested_action_type: 'technical_action',
business_risk: 'medium',
reason: 'Existe acao tecnica pendente.',
evidence: ['ultimo apontamento indica necessidade de validacao'],
}, overrides);
}
test('objeto valido passa e normaliza campos', () => {
const result = validateAiClassification(validRaw({}));
assert.equal(result.valid, true);
assert.equal(result.data.classification, 'ACTIONABLE_NOW');
assert.equal(result.data.is_fallback, false);
});
test('classification invalida falha com erro claro', () => {
const result = validateAiClassification(validRaw({ classification: 'ALGO_INVENTADO' }));
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('classification')));
});
test('JSON invalido/null nao lanca excecao', () => {
assert.doesNotThrow(() => validateAiClassification(null));
assert.doesNotThrow(() => validateAiClassification('nao e objeto'));
assert.equal(validateAiClassification(null).valid, false);
});
test('next_action vazia ou so espacos falha (nao vazia e requisito minimo de qualidade)', () => {
assert.equal(validateAiClassification(validRaw({ next_action: '' })).valid, false);
assert.equal(validateAiClassification(validRaw({ next_action: ' ' })).valid, false);
});
test('confidence fora de 0..1 falha', () => {
const result = validateAiClassification(validRaw({ confidence: 1.5 }));
assert.equal(result.valid, false);
});
test('evidence e limitado a 5 itens', () => {
const result = validateAiClassification(validRaw({ evidence: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }));
assert.equal(result.data.evidence.length, 5);
});
test('todas as classes do dominio sao aceitas', () => {
for (const classification of VALID_CLASSIFICATIONS) {
const result = validateAiClassification(validRaw({ classification }));
assert.equal(result.valid, true, `classe ${classification} deveria ser valida`);
}
});
test('AI_FALLBACK nao esta no dominio de classes que a IA pode retornar', () => {
assert.ok(!VALID_CLASSIFICATIONS.includes('AI_FALLBACK'));
});
test('blocker_type/waiting_for como string "null" sao normalizados para null de verdade', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'null', waiting_for: 'None' }));
assert.equal(result.valid, true);
assert.equal(result.data.blocker_type, null);
assert.equal(result.data.waiting_for, null);
});
test('blocker_type/waiting_for como "n/a" ou string vazia tambem viram null', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'N/A', waiting_for: '' }));
assert.equal(result.data.blocker_type, null);
assert.equal(result.data.waiting_for, null);
});
test('blocker_type/waiting_for com valor real nao sao afetados pela normalizacao', () => {
const result = validateAiClassification(validRaw({ blocker_type: 'fornecedor', waiting_for: 'Patricia' }));
assert.equal(result.data.blocker_type, 'fornecedor');
assert.equal(result.data.waiting_for, 'Patricia');
});
test('AI_CLASSIFIER_VERSION esta definida e e carimbada em toda classificacao valida', () => {
assert.equal(typeof AI_CLASSIFIER_VERSION, 'number');
const result = validateAiClassification(validRaw({}));
assert.equal(result.data.classifier_version, AI_CLASSIFIER_VERSION);
});
test('tecnico interno (aparece em TECNICO do chamado) nunca vira WAITING_VENDOR -- missao secao 6', () => {
const ticket = { tecnico_atribuido: 'Rafael Brito' };
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'Rafael Brito', actionable_today: false }),
ticket,
);
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('tecnico interno')));
});
test('WAITING_VENDOR com fornecedor externo de verdade (nao aparece em TECNICO) continua valido', () => {
const ticket = { tecnico_atribuido: 'Rafael Brito' };
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecimento de baterias', actionable_today: false }),
ticket,
);
assert.equal(result.valid, true);
});
test('WAITING_VENDOR sem ticket informado (uso legado/sem contexto) nao quebra a validacao', () => {
const result = validateAiClassification(
validRaw({ classification: 'WAITING_VENDOR', waiting_for: 'fornecedor XPTO', actionable_today: false }),
);
assert.equal(result.valid, true);
});
test('next_action generico ("Revisar chamado") e rejeitado para ACTIONABLE_NOW -- missao secao 9', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Revisar chamado' }));
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('generico')));
});
test('next_action generico ("Acompanhar") e rejeitado para UNASSIGNED_ACTIONABLE', () => {
const result = validateAiClassification(validRaw({ classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Acompanhar', actionable_today: true }));
assert.equal(result.valid, false);
});
test('next_action concreto ("Realizar inventario das maquinas") passa para ACTIONABLE_NOW', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas' }));
assert.equal(result.valid, true);
});
test('next_action curto tipo "aguardar resposta" e permitido em classes WAITING_* (nao exige verbo de acao do suporte)', () => {
const result = validateAiClassification(validRaw({ classification: 'WAITING_REQUESTER', next_action: 'Aguardar resposta do cliente', actionable_today: false }));
assert.equal(result.valid, true);
});
test('"Validar e executar" e rejeitado -- era o rotulo generico que apareceu em quase toda mensagem real (missao "correcao final" secao 5)', () => {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: 'Validar e executar' }));
assert.equal(result.valid, false);
});
test('demais frases genericas da lista expandida sao rejeitadas: "Prosseguir atendimento", "Executar chamado", "Tratar chamado", "Validar chamado", "Analisar chamado"', () => {
for (const frase of ['Prosseguir atendimento', 'Executar chamado', 'Tratar chamado', 'Validar chamado', 'Analisar chamado']) {
const result = validateAiClassification(validRaw({ classification: 'ACTIONABLE_NOW', next_action: frase }));
assert.equal(result.valid, false, `"${frase}" deveria ser rejeitada`);
}
});
+44
View File
@@ -0,0 +1,44 @@
function toBool(value, defaultValue) {
if (value === undefined || value === null || value === '') return defaultValue;
return String(value).toLowerCase() !== 'false';
}
function toNumber(value, defaultValue) {
const n = Number(value);
return Number.isFinite(n) ? n : defaultValue;
}
function parseConfig(env) {
const e = env || {};
return {
aiEnabled: toBool(e.AI_ENABLED, true),
aiProvider: e.AI_PROVIDER || 'openai',
aiModel: e.AI_MODEL || 'gpt-4o-mini',
candidatePoolSize: toNumber(e.CANDIDATE_POOL_SIZE, 60),
// Igual a candidatePoolSize por padrao -- nenhum candidato elegivel deve ficar sem
// analise semantica por um cap artificial (missao "finalizacao para producao", secao
// 13). AI_FALLBACK fica reservado para falha real de provider/timeout, nao para
// economia de chamadas dentro de um pool que o cache ja deixou barato de repetir.
aiMaxCandidates: toNumber(e.AI_MAX_CANDIDATES, 60),
topN: Math.min(toNumber(e.TOP_N, 10), 10),
cooldownDays: toNumber(e.COOLDOWN_DAYS, 1),
aiConfidenceThreshold: toNumber(e.AI_CONFIDENCE_THRESHOLD, 0.6),
aiMaxContextChars: toNumber(e.AI_MAX_CONTEXT_CHARS, 4000),
devMode: toBool(e.DEV_MODE, true),
shadowMode: toBool(e.SHADOW_MODE, true),
diasSemInteracaoAlerta: toNumber(e.DIAS_SEM_INTERACAO_ALERTA, 2),
horasSlaRisco: toNumber(e.HORAS_SLA_RISCO, 8),
// Gate explicito de envio real -- nunca depende de editar codigo pra habilitar/desabilitar
// (missao "finalizacao para producao", secao 22). Default false: so quem liga
// explicitamente no Config node manda mensagem de verdade.
environment: e.ENVIRONMENT || 'dev',
sendEnabled: toBool(e.SEND_ENABLED, false),
// So importa pra execucoes 'manual' -- protege contra alguem clicar "Execute Workflow" em
// producao (SEND_ENABLED=true) e mandar o digest de novo sem querer. Runs agendadas nunca
// olham esse campo. Default false: manual nunca envia de verdade sem override explicito
// (missao "correcao final", secao 16/17).
forceSend: toBool(e.FORCE_SEND, false),
};
}
module.exports = { parseConfig, toBool, toNumber };
+45
View File
@@ -0,0 +1,45 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { parseConfig } = require('./config');
test('valores padrao quando env vazio', () => {
const config = parseConfig({});
assert.equal(config.aiEnabled, true);
assert.equal(config.aiProvider, 'openai');
assert.equal(config.candidatePoolSize, 60);
assert.equal(config.aiMaxCandidates, 60);
assert.equal(config.topN, 10);
assert.equal(config.aiConfidenceThreshold, 0.6);
assert.equal(config.shadowMode, true);
assert.equal(config.devMode, true);
assert.equal(config.environment, 'dev');
assert.equal(config.sendEnabled, false);
});
test('aiMaxCandidates default e igual a candidatePoolSize default -- nenhum candidato elegivel fica de fora por cap artificial (missao secao 13)', () => {
const config = parseConfig({});
assert.equal(config.aiMaxCandidates, config.candidatePoolSize);
});
test('SEND_ENABLED=true liga o envio real; ENVIRONMENT=production e respeitado', () => {
const config = parseConfig({ SEND_ENABLED: 'true', ENVIRONMENT: 'production' });
assert.equal(config.sendEnabled, true);
assert.equal(config.environment, 'production');
});
test('AI_ENABLED=false desliga a IA', () => {
const config = parseConfig({ AI_ENABLED: 'false' });
assert.equal(config.aiEnabled, false);
});
test('TOP_N nunca passa de 10 mesmo se configurado maior', () => {
const config = parseConfig({ TOP_N: '25' });
assert.equal(config.topN, 10);
});
test('overrides numericos sao respeitados', () => {
const config = parseConfig({ CANDIDATE_POOL_SIZE: '80', AI_MAX_CANDIDATES: '15', COOLDOWN_DAYS: '2' });
assert.equal(config.candidatePoolSize, 80);
assert.equal(config.aiMaxCandidates, 15);
assert.equal(config.cooldownDays, 2);
});
+87
View File
@@ -0,0 +1,87 @@
const { normalizeGlpiText } = require('./normalize');
function normalizeTicket(row, extras) {
const followups = ((extras && extras.followups) || []).map((f) => ({
id: f.id,
date: f.date,
texto: normalizeGlpiText(f.texto),
}));
const tasks = ((extras && extras.tasks) || []).map((t) => ({
id: t.id,
date: t.date,
status: t.status,
texto: normalizeGlpiText(t.texto),
}));
return {
id: Number(row.id),
titulo: normalizeGlpiText(row.titulo),
descricao: normalizeGlpiText(row.descricao),
status: Number(row.status),
status_nome: row.status_nome,
aberto_em: row.aberto_em,
ultima_interacao_em: row.ultima_interacao_em,
dias_aberto: Number(row.dias_aberto),
dias_sem_interacao: Number(row.dias_sem_interacao),
sla_vencido: Number(row.sla_vencido) === 1,
sla_em_risco: Number(row.sla_em_risco) === 1,
horas_ate_sla: row.horas_ate_sla == null ? null : Number(row.horas_ate_sla),
prioridade: Number(row.prioridade),
urgencia: Number(row.urgencia),
impacto: Number(row.impacto),
entidade: row.entidade,
categoria: row.categoria,
tecnico_atribuido: row.tecnico_atribuido,
sem_tecnico: Number(row.sem_tecnico) === 1,
link_glpi: row.link_glpi,
score_criticidade: Number(row.score_criticidade),
followups,
tasks,
};
}
function truncate(text, maxChars) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
return clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 3))}...` : clean;
}
// Corta pelo tamanho total sem achatar quebras de linha -- usado só no corte final do
// contexto inteiro (o texto de cada seção já foi normalizado/truncado individualmente).
function truncatePreservingLines(text, maxChars) {
const str = String(text || '');
return str.length > maxChars ? `${str.slice(0, Math.max(0, maxChars - 3))}...` : str;
}
// Contexto com secoes delimitadas (TITULO / DESCRICAO / STATUS-TECNICO-GRUPO / FOLLOW-UPS /
// TAREFAS), nao uma unica string sem estrutura semantica. O texto de cada campo ja chega
// normalizado (normalizeTicket ja rodou normalizeGlpiText em titulo/descricao/texto).
function buildAiContextText(ticket, maxChars) {
const budget = Math.max(200, Number(maxChars) || 4000);
const followupsText = ticket.followups
.slice(-5)
.map((f) => `- [${f.date}] ${truncate(f.texto, 300) || '(sem conteudo)'}`)
.join('\n');
const tasksText = ticket.tasks
.slice(-5)
.map((t) => `- [${t.date} status=${t.status}] ${truncate(t.texto, 300) || '(sem conteudo)'}`)
.join('\n');
const secoes = [
`TITULO\n${truncate(ticket.titulo, 200) || '(sem titulo)'}`,
`DESCRICAO ORIGINAL\n${truncate(ticket.descricao, 800) || '(sem descricao)'}`,
[
`STATUS: ${ticket.status_nome}`,
`TECNICO: ${ticket.tecnico_atribuido}`,
`GRUPO/ENTIDADE: ${ticket.entidade} | CATEGORIA: ${ticket.categoria}`,
`PRIORIDADE: ${ticket.prioridade} URGENCIA: ${ticket.urgencia} IMPACTO: ${ticket.impacto}`,
`SLA vencido: ${ticket.sla_vencido} | Dias sem interacao: ${ticket.dias_sem_interacao} | Dias aberto: ${ticket.dias_aberto}`,
].join('\n'),
`FOLLOW-UPS\n${followupsText || '(nenhum)'}`,
`TAREFAS\n${tasksText || '(nenhuma)'}`,
];
const contextoCompleto = `Chamado #${ticket.id}\n\n${secoes.join('\n\n')}`;
return truncatePreservingLines(contextoCompleto, budget);
}
module.exports = { normalizeTicket, buildAiContextText, truncate, truncatePreservingLines };
+70
View File
@@ -0,0 +1,70 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeTicket, buildAiContextText, truncate } = require('./context');
function rawRow(overrides) {
return Object.assign({
id: '38611', titulo: ' Colocar etiqueta sala 03 ', descricao: '<p>Descricao original do chamado</p>', status: '2', status_nome: 'Em atendimento',
aberto_em: '2026-08-01 08:00:00', ultima_interacao_em: '2026-08-22 08:00:00',
dias_aberto: '23', dias_sem_interacao: '2', sla_vencido: '1', sla_em_risco: '0', horas_ate_sla: null,
prioridade: '3', urgencia: '3', impacto: '3', entidade: 'Matriz', categoria: 'Facilities',
tecnico_atribuido: 'Sem tecnico atribuido', sem_tecnico: '1', link_glpi: 'https://glpi/x?id=38611',
score_criticidade: '145',
}, overrides);
}
test('normalizeTicket converte tipos e booleans 0/1', () => {
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
assert.equal(t.id, 38611);
assert.equal(t.titulo, 'Colocar etiqueta sala 03');
assert.equal(t.sla_vencido, true);
assert.equal(t.sla_em_risco, false);
assert.equal(t.sem_tecnico, true);
assert.equal(t.dias_aberto, 23);
assert.deepEqual(t.followups, []);
});
test('normalizeTicket normaliza HTML da descricao', () => {
const t = normalizeTicket(rawRow({}), { followups: [], tasks: [] });
assert.equal(t.descricao, 'Descricao original do chamado');
});
test('normalizeTicket normaliza HTML de followups e tarefas', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: '&#60;p&#62;Aguardando retorno&#60;/p&#62;' }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: '<p>Tarefa concluida</p>' }],
});
assert.equal(t.followups[0].texto, 'Aguardando retorno');
assert.equal(t.tasks[0].texto, 'Tarefa concluida');
});
test('truncate corta e adiciona reticencias', () => {
const long = 'a'.repeat(50);
assert.equal(truncate(long, 10), `${'a'.repeat(7)}...`);
assert.equal(truncate('curto', 10), 'curto');
});
test('buildAiContextText respeita o orcamento de caracteres', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: 'x'.repeat(500) }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'y'.repeat(500) }],
});
const text = buildAiContextText(t, 300);
assert.ok(text.length <= 300);
assert.ok(text.includes('#38611'));
});
test('buildAiContextText tem secoes delimitadas: titulo, descricao, follow-ups, tarefas', () => {
const t = normalizeTicket(rawRow({}), {
followups: [{ id: 1, date: '2026-08-20', texto: 'Aguardando resposta' }],
tasks: [{ id: 1, date: '2026-08-19', status: 1, texto: 'Tarefa feita' }],
});
const text = buildAiContextText(t, 4000);
assert.ok(text.includes('TITULO'));
assert.ok(text.includes('DESCRICAO ORIGINAL'));
assert.ok(text.includes('Descricao original do chamado'));
assert.ok(text.includes('FOLLOW-UPS'));
assert.ok(text.includes('Aguardando resposta'));
assert.ok(text.includes('TAREFAS'));
assert.ok(text.includes('Tarefa feita'));
});
+89
View File
@@ -0,0 +1,89 @@
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
// Classes que NUNCA disputam o Top10 operacional -- bloqueadas por definicao, nao por cache.
// Lista expandida na missao "finalizacao para producao" (secao 10): WAITING_APPROVAL,
// PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE entraram aqui (antes so tinham penalidade de
// score, o que deixava WAITING_APPROVAL -- pontuacao base positiva -- competir de verdade).
const BLOCKING_CLASSIFICATIONS = new Set([
'WAITING_REQUESTER', 'WAITING_VENDOR', 'WAITING_APPROVAL', 'SCHEDULED',
'DEPENDENCY_BLOCKED', 'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE',
]);
// Regra centralizada de "isso e critico o bastante pra furar uma exclusao normal" --
// usada tanto pelo cooldown de reenvio quanto pela politica de AI_FALLBACK/
// INSUFFICIENT_CONTEXT (applyFallbackPolicy abaixo). Unico lugar que define "critico".
function isCriticalOverride(ticket) {
const prioridadeAlta = Number(ticket.prioridade) >= 4 || Number(ticket.urgencia) >= 4;
const slaVencidoCritico = Boolean(ticket.sla_vencido) && prioridadeAlta;
const semTecnicoCritico = Boolean(ticket.sem_tecnico) && prioridadeAlta;
return slaVencidoCritico || semTecnicoCritico;
}
// AI_FALLBACK (chamado nunca analisado pela IA de verdade) e INSUFFICIENT_CONTEXT
// (analisado, mas sem evidencia suficiente) NAO disputam o Top10 normalmente -- so entram
// via excecao deterministica forte (isCriticalOverride). Centraliza a regra pedida:
// "AI_FALLBACK nao pode competir como se fosse acionavel confirmado".
function applyFallbackPolicy({ aiClassification, ticket }) {
const critical = isCriticalOverride(ticket);
if (aiClassification.is_fallback) {
return critical
? { blocked: false, decisionReason: 'ai_fallback_critical_override' }
: { blocked: true, decisionReason: 'ai_fallback_no_override' };
}
if (aiClassification.classification === 'INSUFFICIENT_CONTEXT') {
return critical
? { blocked: false, decisionReason: 'insufficient_context_critical_override' }
: { blocked: true, decisionReason: 'insufficient_context_no_override' };
}
return { blocked: false, decisionReason: null };
}
function computeCooldown({ ticket, classification, contextChanged, memory, now, config }) {
const consecutiveDaysSent = memory ? Number(memory.consecutive_days_sent || 0) : 0;
const lastSentAt = memory ? memory.last_sent_at : null;
const nextReviewAt = memory ? memory.next_review_at : null;
const critical = isCriticalOverride(ticket);
let blocked = false;
let penalty = 0;
let reason = 'ok';
let breakCooldown = false;
if (classification === 'NEEDS_COORDINATION') {
blocked = true;
reason = 'needs_coordination';
} else if (nextReviewAt != null && now < nextReviewAt && !contextChanged) {
blocked = true;
reason = 'waiting_next_review';
} else if (BLOCKING_CLASSIFICATIONS.has(classification)) {
// BUG REAL comprovado na Run A anterior: WAITING_VENDOR entrou no Top10 so porque o
// contexto tinha mudado nesta execucao (contextChanged=true). contextChanged decide SE
// reanalisamos (logic/cache.js) -- nunca decide se um chamado que CONTINUA classificado
// numa classe bloqueada pode competir. Por isso este bloqueio e incondicional aqui: se a
// classificacao atual (fresca ou cacheada, tanto faz) esta em BLOCKING_CLASSIFICATIONS,
// fica fora do Top10, ponto. Missao "finalizacao para producao", secao 10.
blocked = true;
reason = `blocked_${classification.toLowerCase()}`;
}
const sentRecently = lastSentAt != null && (now - lastSentAt) < ONE_DAY_MS * 1.5;
if (!contextChanged && sentRecently && consecutiveDaysSent > 0) {
const perDayBase = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' ? 10 : 15;
penalty = Math.min(90, perDayBase * consecutiveDaysSent);
if (!blocked) reason = 'repeat_no_change';
}
if (blocked && critical && classification !== 'NEEDS_COORDINATION') {
breakCooldown = true;
blocked = false;
penalty = Math.round(penalty / 2);
reason = `${reason}_override_critical`;
}
return { blocked, penalty, reason, breakCooldown, consecutiveDaysSent };
}
module.exports = { computeCooldown, isCriticalOverride, applyFallbackPolicy, BLOCKING_CLASSIFICATIONS };
+118
View File
@@ -0,0 +1,118 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { computeCooldown, applyFallbackPolicy, isCriticalOverride } = require('./cooldown');
const CONFIG = { cooldownDays: 1 };
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
const ONE_DAY = 24 * 60 * 60 * 1000;
function ticket(overrides) {
return Object.assign({ sla_vencido: false, prioridade: 2, urgencia: 2 }, overrides);
}
test('WAITING_VENDOR sem mudanca de contexto fica bloqueado (nao repete diariamente)', () => {
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG });
assert.equal(result.blocked, true);
});
test('enviado ontem sem mudanca recebe penalidade crescente', () => {
const memory = { consecutive_days_sent: 3, last_sent_at: NOW - ONE_DAY, next_review_at: null };
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: false, memory, now: NOW, config: CONFIG });
assert.ok(result.penalty > 0);
assert.equal(result.blocked, false);
});
test('BUG REAL corrigido: WAITING_VENDOR continua bloqueado do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
// Antes deste ciclo, contextChanged=true desbloqueava um chamado que CONTINUAVA
// classificado como WAITING_VENDOR apos a reanalise -- foi exatamente o que aconteceu na
// Run A anterior. contextChanged so deve decidir SE reanalisamos (logic/cache.js), nunca
// SE um chamado ainda bloqueado pode competir no Top10.
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_VENDOR', contextChanged: true, memory, now: NOW, config: CONFIG });
assert.equal(result.blocked, true);
assert.equal(result.reason, 'blocked_waiting_vendor');
});
test('chamado sai de WAITING_VENDOR para ACTIONABLE_NOW apos evento real (cronologia) -- deixa de ser bloqueado', () => {
// A reclassificacao em si (peca chegou, virou ACTIONABLE_NOW) e responsabilidade do prompt/
// IA; aqui validamos que, uma vez que a classificacao ja NAO esta mais em
// BLOCKING_CLASSIFICATIONS, o cooldown para de bloquear -- sem depender de contextChanged.
const memory = { consecutive_days_sent: 2, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory, now: NOW, config: CONFIG });
assert.equal(result.blocked, false);
});
test('WAITING_APPROVAL tambem fica fora do Top10 incondicionalmente -- classe adicionada nesta missao', () => {
const result = computeCooldown({ ticket: ticket({}), classification: 'WAITING_APPROVAL', contextChanged: true, memory: null, now: NOW, config: CONFIG });
assert.equal(result.blocked, true);
assert.equal(result.reason, 'blocked_waiting_approval');
});
test('PROJECT_LONG_TERM e DUPLICATE_OR_OBSOLETE tambem ficam fora do Top10 incondicionalmente', () => {
const r1 = computeCooldown({ ticket: ticket({}), classification: 'PROJECT_LONG_TERM', contextChanged: true, memory: null, now: NOW, config: CONFIG });
const r2 = computeCooldown({ ticket: ticket({}), classification: 'DUPLICATE_OR_OBSOLETE', contextChanged: true, memory: null, now: NOW, config: CONFIG });
assert.equal(r1.blocked, true);
assert.equal(r2.blocked, true);
});
test('NEEDS_COORDINATION sempre bloqueado para o Top 10 diario', () => {
const result = computeCooldown({ ticket: ticket({}), classification: 'NEEDS_COORDINATION', contextChanged: false, memory: null, now: NOW, config: CONFIG });
assert.equal(result.blocked, true);
assert.equal(result.reason, 'needs_coordination');
});
test('chamado critico (SLA vencido + prioridade alta) quebra o cooldown', () => {
const memory = { consecutive_days_sent: 4, last_sent_at: NOW - ONE_DAY, next_review_at: NOW + ONE_DAY };
const critico = ticket({ sla_vencido: true, prioridade: 5 });
const result = computeCooldown({ ticket: critico, classification: 'WAITING_VENDOR', contextChanged: false, memory, now: NOW, config: CONFIG });
assert.equal(result.blocked, false);
assert.equal(result.breakCooldown, true);
});
test('sem memoria previa nunca bloqueia por cooldown', () => {
const result = computeCooldown({ ticket: ticket({}), classification: 'ACTIONABLE_NOW', contextChanged: true, memory: null, now: NOW, config: CONFIG });
assert.equal(result.blocked, false);
assert.equal(result.penalty, 0);
});
test('sem tecnico + prioridade alta tambem conta como critico (mesmo sem SLA vencido)', () => {
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 5, sla_vencido: false })), true);
});
test('chamado comum (nao critico) NAO conta como override', () => {
assert.equal(isCriticalOverride(ticket({ sla_vencido: true, prioridade: 2 })), false);
assert.equal(isCriticalOverride(ticket({ sem_tecnico: true, prioridade: 2 })), false);
});
test('AI_FALLBACK comum (nao critico) fica bloqueado por politica -- nao compete como acionavel confirmado', () => {
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: ticket({}) });
assert.equal(result.blocked, true);
assert.equal(result.decisionReason, 'ai_fallback_no_override');
});
test('AI_FALLBACK critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
const critico = ticket({ sla_vencido: true, prioridade: 5 });
const result = applyFallbackPolicy({ aiClassification: { is_fallback: true, classification: 'AI_FALLBACK' }, ticket: critico });
assert.equal(result.blocked, false);
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
});
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal', () => {
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: ticket({}) });
assert.equal(result.blocked, true);
assert.equal(result.decisionReason, 'insufficient_context_no_override');
});
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'INSUFFICIENT_CONTEXT' }, ticket: critico });
assert.equal(result.blocked, false);
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
});
test('classificacao real da IA (nao fallback, nao INSUFFICIENT_CONTEXT) nunca e bloqueada por esta politica', () => {
const result = applyFallbackPolicy({ aiClassification: { is_fallback: false, classification: 'ACTIONABLE_NOW' }, ticket: ticket({}) });
assert.equal(result.blocked, false);
assert.equal(result.decisionReason, null);
});
+29
View File
@@ -0,0 +1,29 @@
// Fila interna de auditoria/calibracao para chamados classificados como INSUFFICIENT_CONTEXT.
// Nao e enviada a ninguem -- serve so pra identificar problemas de preenchimento no GLPI
// (ver secao 10/14 da missao original). Fica gravada dentro do resumo do shadow run.
function buildDataQualityReview(items) {
return items
.filter((item) => item.aiClassification.classification === 'INSUFFICIENT_CONTEXT')
.map((item) => {
const t = item.ticket;
const followups = t.followups || [];
const tasks = t.tasks || [];
const contextSize = (t.descricao || '').length
+ followups.reduce((sum, f) => sum + (f.texto || '').length, 0)
+ tasks.reduce((sum, task) => sum + (task.texto || '').length, 0);
return {
ticket_id: t.id,
title: t.titulo,
ai_reason: item.aiClassification.reason,
context_size: contextSize,
has_description: Boolean(t.descricao && t.descricao.trim().length > 0),
followup_count: followups.length,
task_count: tasks.length,
has_solution: Boolean(t.solucao),
last_interaction_at: t.ultima_interacao_em,
};
});
}
module.exports = { buildDataQualityReview };
+46
View File
@@ -0,0 +1,46 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildDataQualityReview } = require('./dataQuality');
function scoredItem(overrides) {
return Object.assign({
ticket: {
id: 33492, titulo: 'Instalar novo servidor', descricao: 'Descricao do chamado',
followups: [{ id: 1, texto: 'ok' }], tasks: [], ultima_interacao_em: '2025-11-06 15:49:15',
},
aiClassification: { classification: 'INSUFFICIENT_CONTEXT', reason: 'Sem evidencia de proxima acao.' },
}, overrides);
}
test('inclui apenas chamados INSUFFICIENT_CONTEXT', () => {
const items = [
scoredItem({}),
scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } }),
];
const review = buildDataQualityReview(items);
assert.equal(review.length, 1);
assert.equal(review[0].ticket_id, 33492);
});
test('registro traz os campos minimos pedidos para auditoria', () => {
const review = buildDataQualityReview([scoredItem({})]);
const entry = review[0];
assert.equal(entry.ticket_id, 33492);
assert.equal(entry.title, 'Instalar novo servidor');
assert.equal(entry.ai_reason, 'Sem evidencia de proxima acao.');
assert.equal(entry.has_description, true);
assert.equal(entry.followup_count, 1);
assert.equal(entry.task_count, 0);
assert.equal(entry.last_interaction_at, '2025-11-06 15:49:15');
assert.ok(typeof entry.context_size === 'number' && entry.context_size > 0);
});
test('has_description e false quando nao ha descricao real', () => {
const review = buildDataQualityReview([scoredItem({ ticket: { ...scoredItem({}).ticket, descricao: '' } })]);
assert.equal(review[0].has_description, false);
});
test('lista vazia quando nao ha nenhum INSUFFICIENT_CONTEXT', () => {
const items = [scoredItem({ aiClassification: { classification: 'ACTIONABLE_NOW', reason: 'x' } })];
assert.deepEqual(buildDataQualityReview(items), []);
});
+45
View File
@@ -0,0 +1,45 @@
function selectTop(scored, topN, config) {
const eligible = scored.filter((item) => !item.excludedFromTop10);
const excluded = scored.filter((item) => item.excludedFromTop10);
// BUG REAL comprovado em execucao ao vivo (missao "correcao final", secao 14): Array.sort e
// estavel, e muitos chamados empatam em score -- sem um criterio de desempate deterministico,
// a ordem de chegada de $input.all() (que o n8n NAO garante entre branches paralelos --
// cache/ia/fallback se juntam de novo antes daqui) decidia o desempate. Duas execucoes
// seguidas com os MESMOS dados produziram Top10 DIFERENTES so por causa disso, gerando duas
// mensagens reais distintas pro Slack no mesmo dia. ticket.id como criterio secundario
// garante o mesmo resultado sempre que os scores forem os mesmos -- nao muda QUEM vence
// (isso continua sendo so o score), so desempata de forma reproduzivel.
const sorted = [...eligible].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return String(a.ticket.id).localeCompare(String(b.ticket.id));
});
const groupCap = Math.max(1, Math.ceil(topN * 0.5));
const groupCounts = {};
const firstPass = [];
const deferred = [];
for (const item of sorted) {
const count = groupCounts[item.group] || 0;
if (count < groupCap && firstPass.length < topN) {
firstPass.push(item);
groupCounts[item.group] = count + 1;
} else {
deferred.push(item);
}
}
let selected = firstPass;
if (selected.length < topN) {
selected = selected.concat(deferred.slice(0, topN - selected.length));
}
selected = selected.slice(0, topN);
const selectedIds = new Set(selected.map((item) => item.ticket.id));
const notSelected = sorted.filter((item) => !selectedIds.has(item.ticket.id));
return { selected, excluded: excluded.concat(notSelected) };
}
module.exports = { selectTop };
+60
View File
@@ -0,0 +1,60 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { selectTop } = require('./diversity');
function item(id, score, group, excludedFromTop10) {
return { ticket: { id }, score, group, excludedFromTop10: Boolean(excludedFromTop10), aiClassification: {} };
}
test('Top final nunca ultrapassa topN', () => {
const scored = Array.from({ length: 20 }, (_, i) => item(i, 100 - i, 'ranking_geral', false));
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
});
test('itens excluidos do Top10 nunca aparecem selecionados', () => {
const scored = [item(1, 500, 'critico_acionavel', true), item(2, 10, 'ranking_geral', false)];
const { selected } = selectTop(scored, 10, {});
assert.ok(!selected.some((s) => s.ticket.id === 1));
});
test('10 criticos legitimos no mesmo grupo entram todos (diversidade nao bloqueia emergencia real)', () => {
const scored = Array.from({ length: 10 }, (_, i) => item(i, 500 - i, 'critico_acionavel', false));
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
assert.deepEqual(selected.map((s) => s.ticket.id).sort(), scored.map((s) => s.ticket.id).sort());
});
test('BUG REAL comprovado em execucao real: mesmo conjunto de itens empatados em score, em ordens de chegada diferentes, produz o MESMO Top10 -- missao "correcao final" secao 14/15', () => {
// Simula o que aconteceu de verdade: 12 itens com o MESMO score, chegando em duas ordens
// diferentes (n8n nao garante ordem entre branches paralelos). Sem desempate deterministico
// por ticket.id, cada execucao escolhia um subconjunto de 10 diferente -> mensagens
// diferentes -> hash diferente -> idempotencia nunca detectava como "mesmo dia, mesmo digest".
const ids = [38413, 38476, 38477, 38509, 36925, 37691, 37389, 38657, 75, 38611, 38583, 38658];
const ordemA = ids.map((id) => item(id, 200, 'ranking_geral', false));
const ordemB = [...ordemA].reverse();
const resultA = selectTop(ordemA, 10, {});
const resultB = selectTop(ordemB, 10, {});
const idsA = resultA.selected.map((s) => s.ticket.id);
const idsB = resultB.selected.map((s) => s.ticket.id);
assert.deepEqual(idsA, idsB, 'a mesma composicao de candidatos deveria produzir o mesmo Top10, independente da ordem de chegada');
});
test('desempate por ticket.id nao muda QUEM vence quando os scores sao diferentes (so desempata igualdade)', () => {
const scored = [item(2, 100, 'ranking_geral', false), item(1, 200, 'ranking_geral', false)];
const { selected } = selectTop(scored, 10, {});
assert.equal(selected[0].ticket.id, 1, 'score maior continua vencendo, desempate so entra quando ha empate real');
});
test('com grupos variados, ordena por score respeitando diversidade leve', () => {
const scored = [
...Array.from({ length: 8 }, (_, i) => item(`c${i}`, 300 - i, 'critico_acionavel', false)),
item('t1', 250, 'sem_tecnico', false),
item('t2', 240, 'quick_win', false),
];
const { selected } = selectTop(scored, 10, {});
assert.equal(selected.length, 10);
assert.ok(selected.some((s) => s.ticket.id === 't1'));
});
+25
View File
@@ -0,0 +1,25 @@
// AI_FALLBACK e um estado tecnico: significa "a IA nunca analisou este chamado" (erro,
// timeout, IA desligada, JSON invalido, ou cortado pelo limite de AI_MAX_CANDIDATES). NAO e
// uma conclusao da IA -- por isso nunca usa uma das 11 classes que a IA pode retornar
// (ver logic/classification.js). Politica de elegibilidade fica centralizada em
// logic/cooldown.js (applyFallbackPolicy), nao aqui.
const AI_FALLBACK_CLASSIFICATION = 'AI_FALLBACK';
function buildFallbackClassification(reason) {
return {
classification: AI_FALLBACK_CLASSIFICATION,
actionable_today: false,
confidence: 0,
blocker_type: null,
waiting_for: null,
next_action: 'Revisar chamado manualmente (nao analisado pela IA)',
suggested_action_type: 'manual_review',
business_risk: 'medium',
reason: `Fallback deterministico: ${reason}`,
evidence: [],
is_fallback: true,
fallback_reason: reason,
};
}
module.exports = { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION };
+41
View File
@@ -0,0 +1,41 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildFallbackClassification, AI_FALLBACK_CLASSIFICATION } = require('./fallback');
const { VALID_CLASSIFICATIONS } = require('./classification');
test('fallback e sempre seguro e marcado como tal', () => {
const result = buildFallbackClassification('ai_timeout');
assert.equal(result.is_fallback, true);
assert.equal(result.fallback_reason, 'ai_timeout');
assert.equal(result.confidence, 0);
assert.ok(result.reason.includes('Fallback'));
});
test('fallback NUNCA finge ser ACTIONABLE_NOW -- usa AI_FALLBACK, um estado tecnico explicito', () => {
const result = buildFallbackClassification('ai_error');
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
assert.notEqual(result.classification, 'ACTIONABLE_NOW');
assert.equal(result.actionable_today, false);
});
test('AI_FALLBACK nunca e uma classe que a propria IA pode retornar (fica fora do schema enviado a OpenAI)', () => {
assert.ok(!VALID_CLASSIFICATIONS.includes(AI_FALLBACK_CLASSIFICATION));
});
test('AI_MAX_CANDIDATES gera AI_FALLBACK, nao ACTIONABLE_NOW', () => {
const result = buildFallbackClassification('ai_max_candidates_cap');
assert.equal(result.classification, AI_FALLBACK_CLASSIFICATION);
assert.equal(result.fallback_reason, 'ai_max_candidates_cap');
});
test('erro de IA gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('ai_error').classification, AI_FALLBACK_CLASSIFICATION);
});
test('IA desligada gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('ai_disabled').classification, AI_FALLBACK_CLASSIFICATION);
});
test('JSON invalido gera AI_FALLBACK', () => {
assert.equal(buildFallbackClassification('invalid_json').classification, AI_FALLBACK_CLASSIFICATION);
});
+32
View File
@@ -0,0 +1,32 @@
function stableStringify(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const keys = Object.keys(value).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
}
function djb2Hash(str) {
let hash = 5381;
for (let i = 0; i < str.length; i += 1) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao'];
// Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint
// já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma
// informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca
// inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto.
function buildContextFingerprint(ticket) {
const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort();
const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort();
const picked = {};
for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field];
picked.followups = followups;
picked.tasks = tasks;
return djb2Hash(stableStringify(picked));
}
module.exports = { stableStringify, djb2Hash, buildContextFingerprint };
+57
View File
@@ -0,0 +1,57 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildContextFingerprint, stableStringify } = require('./hash');
const { normalizeGlpiText } = require('./normalize');
function baseTicket(overrides) {
return Object.assign({
status: 2,
tecnico_atribuido: 'Wagner',
categoria: 'Rede',
entidade: 'Matriz',
titulo: 'Nobreak desligando',
descricao: 'Nobreak do servidor desligando sozinho',
followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }],
tasks: [{ id: 1, status: 1, date: '2026-08-19 09:00:00', texto: 'Verificado no local' }],
dias_aberto: 10,
dias_sem_interacao: 3,
}, overrides);
}
test('alterar apenas dias_aberto/dias_sem_interacao NAO muda o hash', () => {
const a = buildContextFingerprint(baseTicket({ dias_aberto: 10, dias_sem_interacao: 3 }));
const b = buildContextFingerprint(baseTicket({ dias_aberto: 11, dias_sem_interacao: 4 }));
assert.equal(a, b);
});
test('novo followup MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({}));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }, { id: 2, date: '2026-08-21 08:00:00', texto: 'Peca chegou' }] }));
assert.notEqual(a, b);
});
test('mudanca de tecnico MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Wagner' }));
const b = buildContextFingerprint(baseTicket({ tecnico_atribuido: 'Eric' }));
assert.notEqual(a, b);
});
test('mesmo conteudo normalizado de fontes com encoding HTML diferente gera o MESMO hash', () => {
const textoHtml = normalizeGlpiText('<p>Aguardando cliente</p>');
const textoEscapado = normalizeGlpiText('&#60;p&#62;Aguardando cliente&#60;/p&#62;');
assert.equal(textoHtml, textoEscapado, 'pre-condicao: normalizacao deve produzir o mesmo texto');
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoHtml }] }));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: textoEscapado }] }));
assert.equal(a, b);
});
test('edicao real do conteudo de um followup (mesmo id/date) MUDA o hash', () => {
const a = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Aguardando peca' }] }));
const b = buildContextFingerprint(baseTicket({ followups: [{ id: 1, date: '2026-08-20 10:00:00', texto: 'Peca instalada, testando' }] }));
assert.notEqual(a, b);
});
test('stableStringify ignora ordem de chaves', () => {
assert.equal(stableStringify({ b: 1, a: 2 }), stableStringify({ a: 2, b: 1 }));
});
+62
View File
@@ -0,0 +1,62 @@
// BUG REAL (missao "correcao final", secao 5): ACTIONABLE_NOW tinha um rotulo fixo aqui
// ("Validar e executar") que sempre vencia o next_action de verdade da IA -- por isso TODA
// mensagem real mostrava a mesma frase generica pra praticamente todo chamado acionavel,
// mesmo com a IA ja tendo gerado (e validado como nao-generica, ver logic/classification.js)
// um next_action especifico como "Realizar inventario das maquinas". Removido daqui: agora
// ACTIONABLE_NOW cai no fallback abaixo, que usa o next_action real (truncado).
// UNASSIGNED_ACTIONABLE continua com rotulo fixo de proposito -- pra esse caso "atribuir
// tecnico" E a acao real, independente do que o next_action interno da IA descreva.
const ACTION_LABELS = {
UNASSIGNED_ACTIONABLE: 'Atribuir técnico',
WAITING_APPROVAL: 'Cobrar aprovação',
INSUFFICIENT_CONTEXT: 'Completar informação',
};
function shortActionLabel(aiClassification) {
if (aiClassification.is_fallback) return 'Revisar chamado';
const fromMap = ACTION_LABELS[aiClassification.classification];
if (fromMap) return fromMap;
const raw = String(aiClassification.next_action || 'Revisar chamado').trim();
return raw.length > 40 ? `${raw.slice(0, 37)}...` : raw;
}
function tituloCurto(texto) {
const limpo = String(texto || 'Sem titulo').replace(/\s+/g, ' ').trim();
return limpo.length > 60 ? `${limpo.slice(0, 57)}...` : limpo;
}
function signalLine(ticket) {
const parts = [];
parts.push(ticket.sem_tecnico ? 'SEM TÉCNICO' : ticket.tecnico_atribuido);
if (ticket.sla_vencido) parts.push('SLA vencido');
parts.push(`parado ${ticket.dias_sem_interacao}d`);
return parts.join(' • ');
}
function buildTopMessage(selected, dateStr, config) {
if (!selected.length) {
return `@time Coordenador GLPI - ${dateStr}\n\nNenhum chamado acionável encontrado hoje.`;
}
const header = `@time ${selected.length} chamados para atenção hoje — ${dateStr}`;
const blocks = selected.map((item, index) => {
const t = item.ticket;
const linhasDoBloco = [
`${index + 1}. #${t.id} • ${tituloCurto(t.titulo)}`,
signalLine(t),
`→ ${shortActionLabel(item.aiClassification)}`,
t.link_glpi,
];
return linhasDoBloco.join('\n');
});
const partesDaMensagem = [header, ...blocks];
return partesDaMensagem.join('\n\n');
}
function buildCoordinationDigest(changedBlocked, dateStr) {
if (!changedBlocked.length) return null;
const lines = changedBlocked.map((item) => `#${item.ticket.id} • ${(item.aiClassification && item.aiClassification.reason) || item.exclusionReason}`);
const partesDoDigest = [`Coordenação — bloqueios novos/alterados — ${dateStr}`, '', ...lines];
return partesDoDigest.join('\n');
}
module.exports = { shortActionLabel, tituloCurto, signalLine, buildTopMessage, buildCoordinationDigest };
+106
View File
@@ -0,0 +1,106 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildTopMessage, buildCoordinationDigest, shortActionLabel, tituloCurto } = require('./message');
function selectedItem(overrides) {
return Object.assign({
ticket: {
id: 38611, titulo: 'Colocar etiqueta sala 03', tecnico_atribuido: 'Sem tecnico atribuido',
sem_tecnico: true, sla_vencido: false, dias_sem_interacao: 2, link_glpi: 'https://glpi/x?id=38611',
},
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Atribuir e executar', is_fallback: false },
}, overrides);
}
test('mensagem final contem cabecalho, id, link e acao curta', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(msg.includes('#38611'));
assert.ok(msg.includes('https://glpi/x?id=38611'));
assert.ok(msg.includes('→'));
assert.ok(msg.includes('24/08'));
});
test('mensagem NAO contem score, confianca ou justificativa longa', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(!/confian(c|ç)a/i.test(msg));
assert.ok(!/score/i.test(msg));
});
test('linha de acao e curta (limite de tamanho)', () => {
const label = shortActionLabel({ classification: 'ACTIONABLE_NOW', next_action: 'x'.repeat(100), is_fallback: false });
assert.ok(label.length <= 40);
});
test('tituloCurto limita a 60 caracteres', () => {
const curto = tituloCurto('a'.repeat(100));
assert.ok(curto.length <= 60);
});
test('digest de coordenacao vazio retorna null (nao manda lista igual todo dia)', () => {
assert.equal(buildCoordinationDigest([], '24/08'), null);
});
test('mensagem nao tem linhas em branco duplicadas entre cabecalho e blocos', () => {
const msg = buildTopMessage([selectedItem({}), selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38612 } })], '24/08', {});
assert.ok(!msg.includes('\n\n\n'), 'nao deveria haver 3+ quebras de linha seguidas');
});
test('digest de coordenacao lista apenas os itens passados (novos/alterados)', () => {
const digest = buildCoordinationDigest([{ ticket: { id: 37691 }, aiClassification: { reason: 'aguardando definicao' }, exclusionReason: 'needs_coordination' }], '24/08');
assert.ok(digest.includes('#37691'));
});
test('BUG REAL corrigido: ACTIONABLE_NOW mostra o next_action real da IA, nao o rotulo fixo "Validar e executar" -- missao "correcao final" secao 2/5', () => {
const item = selectedItem({
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: 'Realizar inventário das máquinas', is_fallback: false },
});
const msg = buildTopMessage([item], '24/08', {});
assert.ok(msg.includes('Realizar inventário das máquinas'));
assert.ok(!msg.includes('Validar e executar'));
});
test('UNASSIGNED_ACTIONABLE continua mostrando "Atribuir técnico" (rotulo fixo correto, nao e o bug)', () => {
const item = selectedItem({
aiClassification: { classification: 'UNASSIGNED_ACTIONABLE', next_action: 'Levar patch cord e etiquetar', is_fallback: false },
});
const msg = buildTopMessage([item], '24/08', {});
assert.ok(msg.includes('Atribuir técnico'));
});
test('mensagem nunca contem a sequencia literal de dois caracteres barra-n (bug reportado: \\\\n aparecendo como texto) -- teste obrigatorio #1', () => {
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Chamado ${i}` },
aiClassification: { classification: 'ACTIONABLE_NOW', next_action: `Diagnosticar item ${i}`, is_fallback: false },
}));
const msg = buildTopMessage(dezItens, '24/08', {});
assert.ok(!msg.includes('\\n'), 'mensagem nao pode conter a sequencia literal barra-n');
});
test('cada URL termina exatamente no ticket_id -- o proximo item nunca vaza pra dentro do link anterior -- teste obrigatorio #3', () => {
const dois = [
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38413, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413' } }),
selectedItem({ ticket: { ...selectedItem({}).ticket, id: 38477, link_glpi: 'https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477' } }),
];
const msg = buildTopMessage(dois, '24/08', {});
assert.ok(msg.includes('id=38413\n'), 'a URL do primeiro chamado deve terminar em id=38413 seguido de quebra de linha real, nao emendar no proximo item');
assert.ok(!/id=38413\d/.test(msg), 'nenhum digito deveria colar direto depois do id do primeiro chamado (URL corrompida)');
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38413'));
assert.ok(msg.includes('https://glpi.gtigrupo.com.br/front/ticket.form.php?id=38477'));
});
test('URL fica pura (sem colchetes markdown [url](url)) -- Slack ja auto-linka texto puro -- teste obrigatorio #4', () => {
const msg = buildTopMessage([selectedItem({})], '24/08', {});
assert.ok(!/\[https?:\/\//.test(msg), 'nao deveria embrulhar a URL em markdown [texto](url)');
});
test('mensagem com 10 chamados continua curta (teste obrigatorio #19 -- nao vira relatorio longo)', () => {
const dezItens = Array.from({ length: 10 }, (_, i) => selectedItem({
ticket: { ...selectedItem({}).ticket, id: 38600 + i, titulo: `Titulo do chamado numero ${i} com texto razoavelmente longo` },
}));
const msg = buildTopMessage(dezItens, '25/08', {});
// 10 blocos de ~4 linhas curtas cada + cabecalho: bem abaixo de um "relatorio". Limite
// generoso o suficiente pra nao quebrar por titulos longos, mas que pegaria regressao real
// (ex.: reason/evidence/JSON vazando pra mensagem).
assert.ok(msg.length < 2000, `mensagem ficou com ${msg.length} caracteres, esperado < 2000`);
assert.ok(!/"classification"|"evidence"|"confidence"/.test(msg), 'mensagem nao pode vazar campos internos/JSON');
});
+58
View File
@@ -0,0 +1,58 @@
const NAMED_ENTITIES = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
};
const MAX_DECODE_ITERATIONS = 5;
function decodeEntitiesOnce(text) {
let result = text.replace(/&#(\d+);/g, (match, code) => {
const num = Number(code);
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
});
result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
const num = parseInt(hex, 16);
return Number.isFinite(num) ? String.fromCodePoint(num) : match;
});
for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
result = result.split(entity).join(char);
}
return result;
}
function stripHtmlTags(text) {
return text.replace(/<[^>]*>/g, ' ');
}
// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS
// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros,
// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto
// que nao seja literalmente uma entidade HTML ou uma tag).
function normalizeGlpiText(rawText) {
if (rawText === null || rawText === undefined) return '';
let text = String(rawText);
for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) {
const decoded = decodeEntitiesOnce(text);
if (decoded === text) break;
text = decoded;
}
text = stripHtmlTags(text);
text = text
.split('\n')
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return text;
}
module.exports = { normalizeGlpiText, decodeEntitiesOnce, stripHtmlTags };
+63
View File
@@ -0,0 +1,63 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeGlpiText } = require('./normalize');
test('HTML normal e removido, texto preservado', () => {
assert.equal(normalizeGlpiText('<p>Aguardando cliente</p>'), 'Aguardando cliente');
});
test('entidades HTML escapadas uma vez sao decodificadas e as tags removidas', () => {
const entrada = '&#60;p&#62;Aguardando retorno de Patricia sobre troca da carcaça&#60;/p&#62;';
assert.equal(normalizeGlpiText(entrada), 'Aguardando retorno de Patricia sobre troca da carcaça');
});
test('entidades HTML escapadas duas vezes sao decodificadas iterativamente', () => {
const entrada = '&amp;#60;p&amp;#62;texto&amp;#60;/p&amp;#62;';
assert.equal(normalizeGlpiText(entrada), 'texto');
});
test('entidades nomeadas comuns (amp, nbsp) sao decodificadas', () => {
assert.equal(normalizeGlpiText('Suporte&nbsp;&amp;&nbsp;Manutenção'), 'Suporte & Manutenção');
});
test('texto sem HTML permanece intacto (so trim de espacos)', () => {
assert.equal(normalizeGlpiText(' Servidor não responde '), 'Servidor não responde');
});
test('caracteres acentuados sao preservados', () => {
assert.equal(normalizeGlpiText('<p>Impressão não está funcionando corretamente na área de atendimento</p>'), 'Impressão não está funcionando corretamente na área de atendimento');
});
test('codigo tecnico (numeros, versao, hostname) e preservado', () => {
const entrada = '<p>Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50</p>';
assert.equal(normalizeGlpiText(entrada), 'Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50');
});
test('string vazia retorna string vazia', () => {
assert.equal(normalizeGlpiText(''), '');
});
test('null e undefined retornam string vazia sem lancar excecao', () => {
assert.doesNotThrow(() => normalizeGlpiText(null));
assert.doesNotThrow(() => normalizeGlpiText(undefined));
assert.equal(normalizeGlpiText(null), '');
assert.equal(normalizeGlpiText(undefined), '');
});
test('quebras de linha excessivas e espacos duplicados sao colapsados', () => {
const entrada = '<p>Linha 1</p>\n\n\n\n<p>Linha 2 com espacos</p>';
const resultado = normalizeGlpiText(entrada);
assert.ok(!resultado.includes('\n\n\n'));
assert.ok(!/ {2,}/.test(resultado));
});
test('decodificacao para no limite de iteracoes sem lancar excecao (protecao contra loop)', () => {
const entradaMaliciosa = '&amp;'.repeat(20) + '#60;p&#62;';
assert.doesNotThrow(() => normalizeGlpiText(entradaMaliciosa));
});
test('conteudo normalizado de duas codificacoes diferentes do mesmo texto e identico', () => {
const versaoHtml = '<p>Aguardando cliente</p>';
const versaoEscapada = '&#60;p&#62;Aguardando cliente&#60;/p&#62;';
assert.equal(normalizeGlpiText(versaoHtml), normalizeGlpiText(versaoEscapada));
});
+6
View File
@@ -0,0 +1,6 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
test('node:test runner funciona', () => {
assert.equal(1 + 1, 2);
});
+70
View File
@@ -0,0 +1,70 @@
const { computeCooldown, applyFallbackPolicy } = require('./cooldown');
const CLASSIFICATION_BASE_POINTS = {
ACTIONABLE_NOW: 200,
UNASSIGNED_ACTIONABLE: 180,
WAITING_APPROVAL: 40,
INSUFFICIENT_CONTEXT: 0,
WAITING_REQUESTER: -150,
WAITING_VENDOR: -150,
DEPENDENCY_BLOCKED: -150,
PROJECT_LONG_TERM: -120,
DUPLICATE_OR_OBSOLETE: -200,
SCHEDULED: -1000,
NEEDS_COORDINATION: -1000,
AI_FALLBACK: 0,
};
function computeScore({ ticket, aiClassification, memory, contextChanged, config, now }) {
const classification = aiClassification.classification;
const cooldown = computeCooldown({ ticket, classification, contextChanged, memory, now, config });
// AI_FALLBACK (nunca analisado) e INSUFFICIENT_CONTEXT (analisado, sem evidencia) nao
// disputam o Top10 normalmente -- so entram por excecao deterministica forte, decidida em
// um so lugar (logic/cooldown.js -> applyFallbackPolicy).
const fallbackPolicy = applyFallbackPolicy({ aiClassification, ticket });
const baseline = Math.min(Number(ticket.score_criticidade) || 0, 150) * 0.4;
const semTecnicoBonus = ticket.sem_tecnico ? 30 : 0;
let actionabilityPoints = 0;
if (!aiClassification.is_fallback) {
actionabilityPoints = CLASSIFICATION_BASE_POINTS[classification] ?? 0;
if (actionabilityPoints > 0 && Number(aiClassification.confidence) < config.aiConfidenceThreshold) {
actionabilityPoints /= 2;
}
}
const scheduledNotDue = classification === 'SCHEDULED';
const excludedFromTop10 = cooldown.blocked || classification === 'NEEDS_COORDINATION' || scheduledNotDue || fallbackPolicy.blocked;
const total = baseline + semTecnicoBonus + actionabilityPoints - cooldown.penalty;
let group = 'ranking_geral';
const actionableIsh = classification === 'ACTIONABLE_NOW' || classification === 'UNASSIGNED_ACTIONABLE' || aiClassification.is_fallback;
if (actionableIsh && ticket.sla_vencido && (ticket.prioridade >= 4 || ticket.urgencia >= 4)) group = 'critico_acionavel';
else if (actionableIsh && ticket.sem_tecnico) group = 'sem_tecnico';
else if (actionableIsh && ticket.sla_vencido) group = 'sla_vencido_acionavel';
else if (actionableIsh && ticket.dias_sem_interacao <= 2) group = 'quick_win';
let exclusionReason = null;
if (excludedFromTop10) {
if (scheduledNotDue) exclusionReason = 'scheduled_not_due';
else if (classification === 'NEEDS_COORDINATION') exclusionReason = 'needs_coordination';
else if (fallbackPolicy.blocked) exclusionReason = fallbackPolicy.decisionReason;
else exclusionReason = cooldown.reason;
}
return {
score: Math.round(total * 100) / 100,
group,
excludedFromTop10,
exclusionReason,
// decisionReason fica preenchido mesmo quando NAO excluido (ex.: *_critical_override),
// pra ficar auditavel por que um AI_FALLBACK/INSUFFICIENT_CONTEXT furou a regra.
decisionReason: fallbackPolicy.decisionReason,
breakdown: { baseline, semTecnicoBonus, actionabilityPoints, cooldownPenalty: cooldown.penalty },
cooldown,
};
}
module.exports = { computeScore, CLASSIFICATION_BASE_POINTS };
+102
View File
@@ -0,0 +1,102 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { computeScore } = require('./score');
const CONFIG = { aiConfidenceThreshold: 0.6, cooldownDays: 1 };
const NOW = new Date('2026-08-24T08:00:00Z').getTime();
function ticket(overrides) {
return Object.assign({
id: 1, sla_vencido: false, sem_tecnico: false, dias_sem_interacao: 5, dias_aberto: 5,
prioridade: 3, urgencia: 3, score_criticidade: 100,
}, overrides);
}
function ai(overrides) {
return Object.assign({
classification: 'ACTIONABLE_NOW', actionable_today: true, confidence: 0.9,
is_fallback: false, reason: 'x', evidence: [],
}, overrides);
}
test('chamado acionavel + SLA vencido + prioridade alta cai no grupo critico_acionavel com score alto', () => {
const t = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 });
const result = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.group, 'critico_acionavel');
assert.ok(result.score > 200);
assert.equal(result.excludedFromTop10, false);
});
test('WAITING_REQUESTER sem mudanca nao monopoliza (score baixo)', () => {
const t = ticket({});
const acionavel = computeScore({ ticket: t, aiClassification: ai({}), memory: null, contextChanged: true, config: CONFIG, now: NOW });
const esperando = computeScore({ ticket: t, aiClassification: ai({ classification: 'WAITING_REQUESTER' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.ok(esperando.score < acionavel.score);
});
test('SCHEDULED nao entra antes da data (excluido do Top10)', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'SCHEDULED' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
assert.equal(result.exclusionReason, 'scheduled_not_due');
});
test('sem tecnico e acionavel recebe bonus e grupo sem_tecnico', () => {
const t = ticket({ sem_tecnico: true, sla_vencido: false });
const result = computeScore({ ticket: t, aiClassification: ai({ classification: 'UNASSIGNED_ACTIONABLE' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.group, 'sem_tecnico');
assert.ok(result.breakdown.semTecnicoBonus > 0);
});
test('confianca baixa reduz o peso da acionabilidade', () => {
const t = ticket({});
const altaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.9 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
const baixaConfianca = computeScore({ ticket: t, aiClassification: ai({ confidence: 0.2 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.ok(baixaConfianca.breakdown.actionabilityPoints < altaConfianca.breakdown.actionabilityPoints);
});
test('NEEDS_COORDINATION e excluido do Top10 diario', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'NEEDS_COORDINATION' }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
});
test('fallback nao recebe bonus de acionabilidade (comportamento seguro tipo V1)', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.breakdown.actionabilityPoints, 0);
});
test('AI_FALLBACK comum e excluido do Top10 (nao pode competir como se fosse acionavel confirmado)', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
assert.equal(result.exclusionReason, 'ai_fallback_no_override');
});
test('AI_FALLBACK em chamado critico (SLA vencido + prioridade alta) entra por excecao explicita', () => {
const critico = ticket({ sla_vencido: true, prioridade: 5, urgencia: 5 });
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'AI_FALLBACK', is_fallback: true, confidence: 0 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, false);
assert.equal(result.decisionReason, 'ai_fallback_critical_override');
});
test('INSUFFICIENT_CONTEXT comum fica fora da competicao operacional normal (excluido do Top10)', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
assert.equal(result.exclusionReason, 'insufficient_context_no_override');
});
test('INSUFFICIENT_CONTEXT critico entra somente pela excecao definida', () => {
const critico = ticket({ sem_tecnico: true, prioridade: 4 });
const result = computeScore({ ticket: critico, aiClassification: ai({ classification: 'INSUFFICIENT_CONTEXT', confidence: 0.3 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, false);
assert.equal(result.decisionReason, 'insufficient_context_critical_override');
});
test('BUG REAL comprovado na Run A anterior: WAITING_VENDOR fica fora do Top10 mesmo com contextChanged=true -- missao "finalizacao para producao" secao 10', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_VENDOR', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
assert.equal(result.exclusionReason, 'blocked_waiting_vendor');
});
test('WAITING_APPROVAL (pontuacao base positiva) tambem fica fora do Top10 -- antes so tinha penalidade de score, nao exclusao', () => {
const result = computeScore({ ticket: ticket({}), aiClassification: ai({ classification: 'WAITING_APPROVAL', actionable_today: false, confidence: 0.8 }), memory: null, contextChanged: true, config: CONFIG, now: NOW });
assert.equal(result.excludedFromTop10, true);
});
+64
View File
@@ -0,0 +1,64 @@
const { djb2Hash } = require('./hash');
// Hash estavel do texto exato que seria enviado -- usado como parte da chave de
// idempotencia (junto com report_date/run_type). Duas mensagens com o mesmo conteudo no
// mesmo dia nunca devem gerar dois envios reais.
function computeMessageHash(text) {
return djb2Hash(String(text || ''));
}
// SEND_ENABLED e o gate principal de "isso pode realmente sair pro Slack" -- nunca depende de
// editar codigo (missao "finalizacao para producao", secao 22). Runs 'manual' tem uma segunda
// trava: mesmo com SEND_ENABLED=true (producao), clicar "Execute Workflow" NAO envia de
// verdade a menos que FORCE_SEND tambem esteja ligado -- protege contra envio acidental por
// alguem testando/depurando em producao (missao "correcao final", secao 16/17).
function shouldSendByConfig(config, runType) {
if (!config || !config.sendEnabled) return false;
if (runType === 'manual' && !config.forceSend) return false;
return true;
}
// Idempotencia: so conta como "ja enviado" um registro com status 'success' para a MESMA
// data + tipo de execucao + hash de mensagem. Um retry apos falha (status 'failed') nao
// bloqueia um novo envio -- so um sucesso anterior bloqueia.
function alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash }) {
return (existingRecords || []).some((record) => (
record
&& record.status === 'success'
&& record.report_date === reportDate
&& record.run_type === runType
&& record.message_hash === messageHash
));
}
// Decisao unica de "devo mandar pro Slack agora" -- centraliza os dois motivos possiveis de
// NAO enviar (SEND_ENABLED=false, ou ja enviado com sucesso hoje) num so lugar auditavel.
function evaluateSendGuard({ config, existingRecords, reportDate, runType, messageHash }) {
if (!shouldSendByConfig(config, runType)) {
const reason = runType === 'manual' && config && config.sendEnabled ? 'manual_run_needs_force_send' : 'send_disabled';
return { shouldSend: false, reason };
}
if (alreadySentSuccessfully(existingRecords, { reportDate, runType, messageHash })) {
return { shouldSend: false, reason: 'already_sent' };
}
return { shouldSend: true, reason: 'ok' };
}
function buildEnvioDigestRecord({ reportDate, runType, messageHash, sentAt, status, errorDetail }) {
return {
report_date: reportDate,
run_type: runType,
message_hash: messageHash,
sent_at: sentAt,
status,
error_detail: errorDetail || null,
};
}
module.exports = {
computeMessageHash,
shouldSendByConfig,
alreadySentSuccessfully,
evaluateSendGuard,
buildEnvioDigestRecord,
};
+100
View File
@@ -0,0 +1,100 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
computeMessageHash, shouldSendByConfig, alreadySentSuccessfully, evaluateSendGuard, buildEnvioDigestRecord,
} = require('./slack');
test('computeMessageHash e deterministico para o mesmo texto', () => {
assert.equal(computeMessageHash('ola mundo'), computeMessageHash('ola mundo'));
});
test('computeMessageHash muda quando o texto muda', () => {
assert.notEqual(computeMessageHash('ola mundo'), computeMessageHash('ola mundo!'));
});
test('SEND_ENABLED=false nunca deixa enviar -- missao secao 22/13', () => {
assert.equal(shouldSendByConfig({ sendEnabled: false }), false);
});
test('SEND_ENABLED=true libera o envio (sujeito ainda a idempotencia)', () => {
assert.equal(shouldSendByConfig({ sendEnabled: true }), true);
});
test('alreadySentSuccessfully detecta envio de sucesso identico (mesma data/tipo/hash)', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), true);
});
test('alreadySentSuccessfully ignora registros de falha -- retry apos erro nao fica bloqueado', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
});
test('alreadySentSuccessfully ignora envios de outro dia ou outro hash', () => {
const existing = [{ report_date: '2026-08-24', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
const existing2 = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'xyz', status: 'success' }];
assert.equal(alreadySentSuccessfully(existing2, { reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' }), false);
});
test('evaluateSendGuard: Slack NAO envia com SEND_ENABLED=false -- teste obrigatorio #13', () => {
const result = evaluateSendGuard({ config: { sendEnabled: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'send_disabled');
});
test('evaluateSendGuard: Slack envia com SEND_ENABLED=true e nenhum envio anterior -- teste obrigatorio #14', () => {
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, true);
assert.equal(result.reason, 'ok');
});
test('evaluateSendGuard: envio duplicado e bloqueado -- teste obrigatorio #15', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'success' }];
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'already_sent');
});
test('runType manual NAO envia mesmo com SEND_ENABLED=true, a menos que FORCE_SEND tambem esteja ligado -- missao "correcao final" secao 16', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
});
assert.equal(result.shouldSend, false);
assert.equal(result.reason, 'manual_run_needs_force_send');
});
test('runType manual envia quando FORCE_SEND esta explicitamente ligado -- missao "correcao final" secao 17', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: true }, existingRecords: [], reportDate: '2026-08-25', runType: 'manual', messageHash: 'abc',
});
assert.equal(result.shouldSend, true);
});
test('runType scheduled envia normalmente com SEND_ENABLED=true, independente de FORCE_SEND', () => {
const result = evaluateSendGuard({
config: { sendEnabled: true, forceSend: false }, existingRecords: [], reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc',
});
assert.equal(result.shouldSend, true);
});
test('evaluateSendGuard: retry (mensagem diferente) apos falha anterior nao e bloqueado', () => {
const existing = [{ report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', status: 'failed' }];
const result = evaluateSendGuard({ config: { sendEnabled: true }, existingRecords: existing, reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc' });
assert.equal(result.shouldSend, true);
});
test('buildEnvioDigestRecord monta o registro de sucesso corretamente', () => {
const record = buildEnvioDigestRecord({ reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'success' });
assert.deepEqual(record, {
report_date: '2026-08-25', run_type: 'scheduled', message_hash: 'abc', sent_at: 12345, status: 'success', error_detail: null,
});
});
test('buildEnvioDigestRecord monta o registro de falha com error_detail -- falha nunca registra sucesso (teste obrigatorio #16)', () => {
const record = buildEnvioDigestRecord({
reportDate: '2026-08-25', runType: 'scheduled', messageHash: 'abc', sentAt: 12345, status: 'failed', errorDetail: 'HTTP 500',
});
assert.equal(record.status, 'failed');
assert.equal(record.error_detail, 'HTTP 500');
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
// @requires fallback.js
return $input.all().map((item) => ({ json: { ...item.json, aiClassification: buildFallbackClassification('ai_disabled') } }));
@@ -0,0 +1,14 @@
// @requires message.js
const data = $input.first().json; // { mensagem, selected, excluded, dateStr }
const changedBlocked = data.excluded.filter((item) => {
const isCoordination = item.exclusionReason === 'needs_coordination' || item.aiClassification.classification === 'NEEDS_COORDINATION';
if (!isCoordination) return false;
const memory = item.memory;
if (!memory) return true;
return memory.ai_classification !== item.aiClassification.classification || memory.last_ai_reason !== item.aiClassification.reason;
});
const digest = buildCoordinationDigest(changedBlocked, data.dateStr);
return [{ json: { ...data, digest, changedBlocked } }];
@@ -0,0 +1,17 @@
// @requires config.js
// Esta instancia n8n bloqueia acesso a $env em Code nodes (N8N_BLOCK_ENV_ACCESS_IN_NODE),
// entao a configuracao vem do node "Config - Coordenador GLPI v2" (mesmo padrao do V1),
// nao de variaveis de ambiente reais.
const configSource = $('Config - Coordenador GLPI v2').first().json;
const config = parseConfig(configSource);
const glpiBaseUrl = String(configSource.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
const queryTemplate = /* __SQL_CANDIDATOS_V2__ */;
const query = queryTemplate
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', String(config.diasSemInteracaoAlerta))
.replaceAll('{HORAS_SLA_RISCO}', String(config.horasSlaRisco))
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl)
.replaceAll('{CANDIDATE_POOL_SIZE}', String(config.candidatePoolSize));
return [{ json: { query, config } }];
@@ -0,0 +1,24 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('SQL de candidatos V2 preserva a exclusao administrativa do V1', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes("NOT LIKE 'ADMINISTRATIVO%'"));
assert.ok(sql.includes("NOT LIKE 'DESENVOLVIMENTO%'"));
assert.ok(sql.includes("NOT LIKE 'INFRA%'"));
assert.ok(sql.includes('is_deleted = false'));
assert.ok(sql.includes('status NOT IN (5, 6)'));
});
test('SQL de candidatos V2 NAO tem mais LIMIT fixo em 10 (funil maior)', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes('LIMIT {CANDIDATE_POOL_SIZE}'));
assert.ok(!sql.includes('LIMIT 10'));
});
test('SQL de candidatos V2 traz a descricao real do chamado (t.content) para a IA', () => {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes('t.content AS descricao'));
});
@@ -0,0 +1,8 @@
const candidatos = $input.all().map((item) => item.json);
const ids = candidatos.map((c) => Number(c.id)).filter((id) => Number.isInteger(id));
const idsList = ids.length ? ids.join(',') : '0';
const queryTemplate = /* __SQL_CONTEXTO__ */;
const query = queryTemplate.replaceAll('{IDS}', idsList);
return [{ json: { query, candidateIds: ids } }];
@@ -0,0 +1,11 @@
const config = $('Config - Coordenador GLPI v2').first().json;
const glpiBaseUrl = String(config.glpi_base_url || '').replace(/\/$/, '').replace(/'/g, "''");
const queryTemplate = /* __SQL_LEGADO_V1_VERBATIM__ */;
const query = queryTemplate
.replaceAll('{DIAS_SEM_INTERACAO_ALERTA}', '2')
.replaceAll('{HORAS_SLA_RISCO}', '8')
.replaceAll('{GLPI_BASE_URL}', glpiBaseUrl);
return [{ json: { query } }];
@@ -0,0 +1,9 @@
// @requires message.js
const items = $input.all().map((item) => item.json);
const selected = items.filter((data) => data._grupo === 'selected').sort((a, b) => b.score - a.score);
const excluded = items.filter((data) => data._grupo === 'excluded');
const dateStr = new Date().toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' });
const mensagem = buildTopMessage(selected, dateStr, selected.length ? selected[0].config : {});
return [{ json: { mensagem, selected, excluded, dateStr } }];
@@ -0,0 +1,83 @@
// @requires dataQuality.js
// Este node roda depois de "Merge - Unir Shadow" (append: Code - Montar Digest Coordenacao +
// MySQL - Legado Top 10 (Shadow)), que existe so para GARANTIR que os dois ja executaram --
// os dados continuam sendo lidos por nome, nao pelo item mesclado, para nao depender de ordem.
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
const legacyRows = $('MySQL - Legado Top 10 (Shadow)').all().map((item) => item.json);
const legacyIds = legacyRows.map((row) => Number(row.id));
const v2Ids = digestData.selected.map((item) => Number(item.ticket.id));
const added = v2Ids.filter((id) => !legacyIds.includes(id));
const removed = legacyIds.filter((id) => !v2Ids.includes(id));
const addedReasons = added.map((id) => {
const item = digestData.selected.find((s) => Number(s.ticket.id) === id);
return { id, motivo: item ? `${item.aiClassification.classification} - ${item.aiClassification.reason}` : 'desconhecido' };
});
const removedReasons = removed.map((id) => {
const excludedItem = digestData.excluded.find((e) => Number(e.ticket.id) === id);
return { id, motivo: excludedItem ? (excludedItem.exclusionReason || excludedItem.aiClassification.classification) : 'nao estava nos candidatos V2' };
});
// --- observabilidade (secao 24 da missao) ---
const allItems = [...digestData.selected, ...digestData.excluded];
const cacheHits = allItems.filter((i) => i.resolucao === 'cache').length;
const aiRequested = allItems.filter((i) => i.resolucao === 'ia').length;
const aiFallbackCapCount = allItems.filter((i) => i.resolucao === 'fallback_cap').length;
const aiSuccess = allItems.filter((i) => i.resolucao === 'ia' && !i.aiClassification.is_fallback).length;
const aiErrors = allItems.filter((i) => i.resolucao === 'ia' && i.aiClassification.is_fallback).length;
const aiFallbackCount = allItems.filter((i) => i.aiClassification.is_fallback).length;
const aiFallbackByReason = {};
allItems
.filter((i) => i.aiClassification.is_fallback)
.forEach((i) => {
const r = i.aiClassification.fallback_reason || 'unknown';
aiFallbackByReason[r] = (aiFallbackByReason[r] || 0) + 1;
});
const classificationCounts = {};
allItems.forEach((i) => {
const c = i.aiClassification.classification;
classificationCounts[c] = (classificationCounts[c] || 0) + 1;
});
const actionableRealCount = allItems.filter((i) => !i.aiClassification.is_fallback
&& (i.aiClassification.classification === 'ACTIONABLE_NOW' || i.aiClassification.classification === 'UNASSIGNED_ACTIONABLE')).length;
const criticalOverrideCount = allItems.filter((i) => i.decisionReason && String(i.decisionReason).endsWith('_critical_override')).length;
const observability = {
candidate_count: allItems.length,
cache_hits: cacheHits,
cache_misses: allItems.length - cacheHits,
ai_requested: aiRequested,
ai_success: aiSuccess,
ai_errors: aiErrors,
ai_fallback_count: aiFallbackCount,
ai_fallback_cap_count: aiFallbackCapCount,
ai_fallback_by_reason: aiFallbackByReason,
classification_counts: classificationCounts,
insufficient_context_count: classificationCounts.INSUFFICIENT_CONTEXT || 0,
actionable_real_count: actionableRealCount,
critical_override_count: criticalOverrideCount,
top10_count: digestData.selected.length,
blocked_count: digestData.excluded.length,
coordination_count: classificationCounts.NEEDS_COORDINATION || 0,
};
const dataQualityReview = buildDataQualityReview(allItems);
return [{
json: {
run_at: Date.now(),
legacy_ids: JSON.stringify(legacyIds),
v2_ids: JSON.stringify(v2Ids),
added: JSON.stringify(added),
removed: JSON.stringify(removed),
summary: JSON.stringify({ addedReasons, removedReasons, observability, dataQualityReview }),
mensagem: digestData.mensagem,
digest: digestData.digest,
},
}];
@@ -0,0 +1,14 @@
// @requires cooldown.js score.js
return $input.all().map((item) => {
const data = item.json;
const contextChanged = data.resolucao !== 'cache';
const result = computeScore({
ticket: data.ticket,
aiClassification: data.aiClassification,
memory: data.memory,
contextChanged,
config: data.config,
now: data.now,
});
return { json: { ...data, contextChanged, scoreResult: result } };
});
@@ -0,0 +1,53 @@
// @requires config.js cache.js classification.js
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
const config = parseConfig($('Config - Coordenador GLPI v2').first().json);
const now = Date.now();
// $input vem do Merge - Aguardar Memoria (so existe para GARANTIR que os candidatos e a
// memoria ja foram buscados antes deste node rodar); os dados de verdade sao lidos por nome.
const items = $('Code - Mesclar Contexto e Calcular Hash').all().map((item) => item.json); // { ticket, contextHash }
const memoryRows = $('Data Table - Buscar Memoria').all().map((item) => item.json);
const memoryByTicket = {};
for (const row of memoryRows) memoryByTicket[String(row.ticket_id)] = row;
const withDecision = items.map((item) => {
const memory = memoryByTicket[String(item.ticket.id)] || null;
// Chave de cache = conteudo do chamado + versao do classificador (logic/cache.js). Bump de
// AI_CLASSIFIER_VERSION forca cache miss em TODO chamado, mesmo sem nenhuma mudanca real no
// GLPI -- sem precisar apagar a Data Table na mao (missao "finalizacao para producao", secao 12).
const cacheKey = buildCacheKey(item.contextHash, AI_CLASSIFIER_VERSION);
const analysis = needsReanalysis({ memory, currentHash: cacheKey, now });
return {
ticket: item.ticket, contextHash: item.contextHash, cacheKey, memory, needsAiReason: analysis.reason, needsAiRaw: analysis.needsAi,
};
});
const needingAi = withDecision
.filter((item) => item.needsAiRaw)
.sort((a, b) => (Number(b.ticket.score_criticidade) || 0) - (Number(a.ticket.score_criticidade) || 0));
const allowedIds = new Set(needingAi.slice(0, config.aiMaxCandidates).map((item) => item.ticket.id));
return withDecision.map((item) => {
let resolucao;
if (!item.needsAiRaw) {
resolucao = 'cache';
} else if (allowedIds.has(item.ticket.id)) {
resolucao = 'ia';
} else {
resolucao = 'fallback_cap';
}
return {
json: {
ticket: item.ticket,
contextHash: item.contextHash,
cacheKey: item.cacheKey,
memory: item.memory,
needsAiReason: item.needsAiReason,
resolucao,
precisa_ia: resolucao === 'ia',
config,
now,
},
};
});
@@ -0,0 +1,26 @@
// @requires config.js hash.js slack.js
// Roda depois de "Data Table - Buscar Envios Recentes" (fetch por report_date=hoje), que existe
// so pra trazer os registros de envio ja existentes; os dados do digest sao lidos por nome
// (mesmo padrao ja usado em Code - Comparar Legacy x V2).
const digestData = $('Code - Montar Digest Coordenacao').first().json; // { mensagem, selected, excluded, dateStr, digest, changedBlocked }
const config = digestData.selected.length ? digestData.selected[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
const existingRecords = $input.all().map((item) => item.json);
// $execution.mode: 'manual' quando disparado pelo Manual Trigger, senao ('trigger' no
// Schedule Trigger) tratamos como execucao agendada. Nao inventa um mecanismo novo de deteccao.
const runType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
const messageHash = computeMessageHash(digestData.mensagem);
const guard = evaluateSendGuard({
config, existingRecords, reportDate: digestData.dateStr, runType, messageHash,
});
return [{
json: {
mensagem: digestData.mensagem,
dateStr: digestData.dateStr,
runType,
messageHash,
shouldSend: guard.shouldSend,
reason: guard.reason,
},
}];
@@ -0,0 +1,10 @@
// Roda depois de "Data Table - Registrar Envio Digest (Falha)" ja ter gravado o status=failed.
// So agora propaga o erro de verdade, pra a execucao no n8n ficar visivelmente com status de
// erro (missao "finalizacao para producao", secao 19: "Nao silenciar erro").
const registro = $input.first().json;
throw new Error(`Envio ao Slack falhou (report_date=${registro.report_date}, run_type=${registro.run_type}): ${registro.error_detail}`);
// eslint-disable-next-line no-unreachable -- linha morta de proposito: o validador estatico do
// n8n-mcp exige um "return" literal no texto do node (heuristica que nao reconhece um node cujo
// unico proposito e lancar erro -- confirmado via validate_node, mesmo comportamento do falso
// positivo do .join() ja documentado neste projeto). Nunca executa; o throw acima sempre sai primeiro.
return [];
@@ -0,0 +1,28 @@
// @requires normalize.js context.js hash.js
const candidatosBase = $('Code - Normalizar Candidatos Base').all().map((item) => item.json);
const contextRows = $input.all().map((item) => item.json);
function groupBy(rows, key) {
const map = {};
for (const row of rows) {
const k = String(row[key]);
if (!map[k]) map[k] = [];
map[k].push(row);
}
return map;
}
const followupRows = contextRows.filter((row) => row.tipo === 'followup');
const taskRows = contextRows.filter((row) => row.tipo === 'task');
const followupsByTicket = groupBy(followupRows, 'tickets_id');
const tasksByTicket = groupBy(taskRows, 'tickets_id');
return candidatosBase.map((base) => {
const idKey = String(base.id);
const followups = (followupsByTicket[idKey] || []).map((f) => ({ id: f.id, date: f.date, texto: f.texto }));
const tasks = (tasksByTicket[idKey] || []).map((t) => ({ id: t.id, date: t.date, status: t.status, texto: t.texto }));
const ticket = normalizeTicket(base, { followups, tasks });
const contextHash = buildContextFingerprint(ticket);
return { json: { ticket, contextHash } };
});
@@ -0,0 +1,3 @@
// @requires normalize.js context.js
const items = $input.all();
return items.map((item) => ({ json: normalizeTicket(item.json, { followups: [], tasks: [] }) }));
@@ -0,0 +1,84 @@
// @requires normalize.js context.js
const VALID_CLASSIFICATIONS = [
'ACTIONABLE_NOW', 'UNASSIGNED_ACTIONABLE', 'WAITING_REQUESTER', 'WAITING_VENDOR',
'WAITING_APPROVAL', 'SCHEDULED', 'DEPENDENCY_BLOCKED', 'NEEDS_COORDINATION',
'PROJECT_LONG_TERM', 'DUPLICATE_OR_OBSOLETE', 'INSUFFICIENT_CONTEXT',
];
const systemPrompt = [
'Voce e um analisador operacional de chamados de TI (GLPI). Voce NAO e um atendente e NAO conversa com o usuario.',
'Use SOMENTE as informacoes fornecidas abaixo sobre o chamado. Nunca invente fatos.',
'Distinga evidencia (o que esta escrito no chamado) de inferencia (sua conclusao).',
'SLA vencido sozinho NAO e evidencia de que existe uma acao executavel agora.',
'Idade do chamado sozinha NAO e evidencia de acionabilidade.',
'',
'=== O QUE SIGNIFICA ACIONAVEL (ACTIONABLE_NOW / UNASSIGNED_ACTIONABLE) ===',
'ACTIONABLE_NOW significa: existe pelo menos uma acao concreta que um integrante do suporte pode executar AGORA para fazer o chamado avancar. NAO precisa significar que o chamado sera resolvido integralmente hoje -- precisa significar que o suporte consegue fazer algo util agora.',
'Isso INCLUI, entre outras: executar diagnostico; testar equipamento; verificar configuracao; solicitar informacao ao cliente (quando ainda NAO foi solicitada); entrar em contato com fornecedor (quando ainda NAO foi contatado); marcar/agendar visita; combinar horario; atribuir tecnico; realizar inventario; validar cenario; executar instalacao; iniciar migracao; cobrar informacao que ainda NAO foi solicitada; atualizar configuracao; reproduzir problema; levantar requisitos; ou qualquer outro proximo passo concreto sob controle do suporte.',
'',
'=== CONTATAR != AGUARDAR (diferenca critica) ===',
'Precisar contatar alguem (cliente ou fornecedor) que AINDA NAO foi contatado e ACIONAVEL AGORA: ex. "Precisamos perguntar ao cliente qual horario pode realizar a manutencao" -> ACTIONABLE_NOW, next_action "Contatar cliente para combinar horario". "Precisamos pedir orcamento ao fornecedor" -> ACTIONABLE_NOW.',
'Ja ter contatado e estar esperando resposta e WAITING_*: ex. "Cliente ja foi contatado em 23/08 e estamos aguardando resposta" -> WAITING_REQUESTER. "Orcamento ja solicitado; aguardando retorno do fornecedor" -> WAITING_VENDOR.',
'So classifique WAITING_REQUESTER, WAITING_VENDOR ou WAITING_APPROVAL quando existir evidencia POSITIVA no historico de que (1) a acao necessaria ja foi executada pelo suporte E (2) existe uma dependencia externa ainda aberta. Sem essa evidencia dupla, NAO assuma espera -- procure se existe uma acao ainda nao realizada primeiro.',
'',
'=== TECNICO INTERNO NAO E FORNECEDOR ===',
'O campo TECNICO do chamado lista responsaveis/tecnicos internos da equipe de suporte. Se uma pendencia depende de alguem listado nesse campo, NUNCA classifique como WAITING_VENDOR (fornecedor externo) -- normalmente e ACTIONABLE_NOW (se o proprio suporte pode agir) ou UNASSIGNED_ACTIONABLE (se depende de atribuicao). WAITING_VENDOR e reservado para fornecedores/terceiros genuinamente externos a equipe.',
'',
'=== STATUS "PLANEJADO" NAO SIGNIFICA BLOQUEADO ===',
'STATUS Planejado sozinho NAO significa WAITING_REQUESTER, WAITING_VENDOR nem SCHEDULED. Se existe tarefa ou apontamento descrevendo um passo operacional disponivel agora, o chamado pode ser ACTIONABLE_NOW mesmo estando "Planejado".',
'Use SCHEDULED apenas quando existir data, horario, janela ou evento futuro explicito no chamado (ou evidencia inequivoca de que nenhuma acao pode/deve ocorrer antes dessa data).',
'',
'=== CRONOLOGIA E OBRIGATORIA ===',
'Interprete os apontamentos em ordem temporal -- o mais recente relevante prevalece sobre um bloqueio antigo ja superado. Exemplo: "20/08 aguardando fornecedor" seguido de "22/08 pecas chegaram" seguido de "23/08 falta instalar" -> ACTIONABLE_NOW (instalar pecas), NAO WAITING_VENDOR. Nao se prenda a um estado antigo se um apontamento mais novo o superou.',
'',
'=== NEXT_ACTION DEVE SER CONCRETO ===',
'Para ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE, next_action precisa nomear a acao real: "Realizar inventario das maquinas", "Contatar cliente para combinar recolha", "Testar nobreak com carga", "Atribuir tecnico para visita", "Iniciar migracao dos e-mails", "Validar configuracao da impressora", "Solicitar dados de acesso ao cliente".',
'NUNCA aceite como next_action frases genericas que nao provam acionabilidade nenhuma: "Revisar chamado", "Dar andamento", "Analisar situacao", "Verificar chamado", "Acompanhar". Se voce so consegue pensar numa frase generica dessas, reconsidere a classificacao -- provavelmente falta evidencia concreta.',
'',
'=== QUANDO USAR INSUFFICIENT_CONTEXT ===',
'Antes de classificar como INSUFFICIENT_CONTEXT, pergunte-se: existe alguma acao concreta que o suporte consegue executar mesmo sem informacao completa? Falta de diagnostico nao significa falta de acao -- ex. "nao sabemos por que o notebook nao conecta" ainda pode ser ACTIONABLE_NOW com next_action "Realizar diagnostico de conectividade".',
'Use INSUFFICIENT_CONTEXT somente quando realmente nao for possivel determinar um proximo passo razoavelmente especifico -- nem mesmo um passo de diagnostico/investigacao.',
'',
'Identifique sempre: existe acao executavel hoje? quem pode executar? existe bloqueio? quem segura o bloqueio (interno ou externo)? qual a proxima acao concreta? existe data conhecida para revisao? existe evidencia nos apontamentos, em ordem cronologica, que justifique a conclusao?',
`Responda em JSON estrito com os campos: classification (uma de: ${VALID_CLASSIFICATIONS.join(', ')}), actionable_today (boolean), confidence (0 a 1), blocker_type, waiting_for, next_action (curto, poucas palavras, sem paragrafo, deve ser uma acao concreta quando ACTIONABLE_NOW/UNASSIGNED_ACTIONABLE), suggested_action_type, business_risk (low|medium|high), reason (uma frase), evidence (lista curta de evidencias textuais encontradas, em ordem cronologica quando relevante).`,
].join('\n');
return $input.all().map((item) => {
const data = item.json;
const userPrompt = buildAiContextText(data.ticket, data.config.aiMaxContextChars);
const requestBody = {
model: data.config.aiModel,
temperature: 0.1,
response_format: {
type: 'json_schema',
json_schema: {
name: 'ticket_classification',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
classification: { type: 'string', enum: VALID_CLASSIFICATIONS },
actionable_today: { type: 'boolean' },
confidence: { type: 'number' },
blocker_type: { type: ['string', 'null'] },
waiting_for: { type: ['string', 'null'] },
next_action: { type: 'string' },
suggested_action_type: { type: 'string' },
business_risk: { type: 'string' },
reason: { type: 'string' },
evidence: { type: 'array', items: { type: 'string' } },
},
required: ['classification', 'actionable_today', 'confidence', 'blocker_type', 'waiting_for', 'next_action', 'suggested_action_type', 'business_risk', 'reason', 'evidence'],
},
},
},
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
};
return { json: { ...data, requestBody } };
});
@@ -0,0 +1,69 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
// O prompt em si nao pode ser testado deterministicamente sem chamar a IA de verdade -- estes
// testes garantem que as REGRAS exigidas pela missao "finalizacao para producao" continuam
// escritas no prompt (nao foram removidas/reescritas por acidente numa edicao futura). A prova
// comportamental real fica nas execucoes reais contra o GLPI (Run com AI_CLASSIFIER_VERSION
// nova), inspecionadas manualmente no relatorio.
function readPrompt() {
return fs.readFileSync(path.join(__dirname, 'prepare-ai-prompt.node.js'), 'utf8');
}
test('prompt define acionabilidade de forma pratica ("suporte consegue fazer algo util agora")', () => {
const src = readPrompt();
assert.ok(/suporte consegue fazer algo util agora/i.test(src));
});
test('prompt distingue contatar (ainda nao feito) de aguardar (ja feito) -- teste obrigatorio #2/#3', () => {
const src = readPrompt();
assert.ok(/AINDA NAO foi contatado/.test(src));
assert.ok(/ja foi contatado/i.test(src) || /Ja ter contatado/.test(src));
});
test('prompt exige evidencia dupla (acao ja executada + dependencia externa aberta) pra WAITING_*', () => {
const src = readPrompt();
assert.ok(/evidencia POSITIVA/.test(src));
assert.ok(/dependencia externa ainda aberta/.test(src));
});
test('prompt instrui que tecnico interno (campo TECNICO) nunca vira WAITING_VENDOR -- teste obrigatorio #1', () => {
const src = readPrompt();
assert.ok(/NUNCA classifique como WAITING_VENDOR/.test(src));
assert.ok(/tecnicos internos da equipe de suporte/.test(src));
});
test('prompt instrui que STATUS Planejado sozinho nao significa bloqueio -- teste obrigatorio #6', () => {
const src = readPrompt();
assert.ok(/Planejado sozinho NAO significa/.test(src));
});
test('prompt exige data/horario/janela explicita para SCHEDULED', () => {
const src = readPrompt();
assert.ok(/SCHEDULED apenas quando existir data, horario, janela/.test(src));
});
test('prompt exige interpretacao cronologica (apontamento mais recente prevalece) -- teste obrigatorio #8', () => {
const src = readPrompt();
assert.ok(/ordem temporal/.test(src));
assert.ok(/mais recente relevante prevalece/.test(src));
});
test('prompt rejeita next_action generico com exemplos explicitos', () => {
const src = readPrompt();
for (const frase of ['Revisar chamado', 'Dar andamento', 'Analisar situacao', 'Verificar chamado', 'Acompanhar']) {
assert.ok(src.includes(frase), `prompt deveria citar "${frase}" como exemplo generico rejeitado`);
}
});
test('prompt pede para considerar acao de diagnostico antes de usar INSUFFICIENT_CONTEXT -- teste obrigatorio #7', () => {
const src = readPrompt();
assert.ok(/Falta de diagnostico nao significa falta de acao/.test(src));
});
test('prompt continua exigindo resposta em JSON estrito com o dominio de classes correto', () => {
const src = readPrompt();
assert.ok(src.includes('VALID_CLASSIFICATIONS.join'));
});
@@ -0,0 +1,25 @@
const ONE_DAY = 24 * 60 * 60 * 1000;
// So roda depois de "Data Table - Registrar Envio Digest (Sucesso)" -- ou seja, so depois do
// Slack confirmar sucesso de verdade (missao "finalizacao para producao", secao 19). Os dados
// do Top10 sao lidos por nome (nao por $input, que aqui e o registro do envio, nao os tickets).
return $('Code - Selecionar Top 10').all()
.filter((item) => item.json._grupo === 'selected')
.map((item) => {
const data = item.json;
const memory = data.memory;
const sentRecently = memory && memory.last_sent_at != null && (data.now - memory.last_sent_at) < ONE_DAY * 1.5;
const consecutiveDaysSent = sentRecently && !data.contextChanged ? (Number(memory.consecutive_days_sent || 0) + 1) : 1;
const timesSent = (memory ? Number(memory.times_sent || 0) : 0) + 1;
const timesSent30d = (memory ? Number(memory.times_sent_30d || 0) : 0) + 1;
return {
json: {
ticket_id: data.ticket.id,
last_sent_at: data.now,
times_sent: timesSent,
times_sent_30d: timesSent30d,
consecutive_days_sent: consecutiveDaysSent,
updated_at: data.now,
},
};
});
@@ -0,0 +1,30 @@
const items = $input.all().map((item) => item.json);
const ONE_DAY = 24 * 60 * 60 * 1000;
return items.map((data) => {
const cls = data.aiClassification.classification;
let nextReviewAt = null;
if (cls === 'WAITING_REQUESTER' || cls === 'WAITING_VENDOR') nextReviewAt = data.now + 3 * ONE_DAY;
else if (cls === 'SCHEDULED' || cls === 'NEEDS_COORDINATION') nextReviewAt = data.now + 2 * ONE_DAY;
return {
json: {
ticket_id: data.ticket.id,
last_analyzed_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_analyzed_at : data.now),
// Guarda a chave composta (hash de conteudo + AI_CLASSIFIER_VERSION), nao o hash puro --
// e o que faz o cache invalidar sozinho quando o prompt/classificador muda de versao,
// sem precisar apagar a Data Table na mao. Ver logic/cache.js -> buildCacheKey.
last_context_hash: data.cacheKey,
last_meaningful_change_at: data.contextChanged ? data.now : (data.memory ? data.memory.last_meaningful_change_at : data.now),
ai_classification: cls,
ai_confidence: data.aiClassification.confidence,
blocker_type: data.aiClassification.blocker_type,
waiting_for: data.aiClassification.waiting_for,
next_action: data.aiClassification.next_action,
next_review_at: nextReviewAt,
last_score: data.scoreResult.score,
last_ai_reason: data.aiClassification.reason,
updated_at: data.now,
},
};
});
@@ -0,0 +1,5 @@
// Branch "nao deve enviar" do guard (SEND_ENABLED=false ou ja enviado com sucesso hoje).
// Node terminal, so pra deixar visivel na execucao do n8n por que nao mandou nada -- sem
// escrever em nenhuma Data Table (nao e uma falha, e uma decisao esperada).
const data = $input.first().json;
return [{ json: { skipped: true, reason: data.reason, dateStr: data.dateStr, runType: data.runType } }];
@@ -0,0 +1,18 @@
// @requires hash.js slack.js
// So roda no output de erro do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
// Registra status=failed com o erro real ANTES de propagar a falha (proximo node lanca o
// erro de verdade, pra execucao ficar visivelmente vermelha no n8n -- nao silenciar).
const guardData = $('Code - Guard de Envio Slack').first().json;
const errorItem = $input.first().json || {};
const errorMessage = String(errorItem.error || errorItem.message || 'Erro desconhecido ao enviar mensagem ao Slack').slice(0, 500);
const record = buildEnvioDigestRecord({
reportDate: guardData.dateStr,
runType: guardData.runType,
messageHash: guardData.messageHash,
sentAt: Date.now(),
status: 'failed',
errorDetail: errorMessage,
});
return [{ json: record }];
@@ -0,0 +1,14 @@
// @requires hash.js slack.js
// So roda no output de sucesso do node "Slack - Enviar Mensagem" (onError: continueErrorOutput).
// Registrar sent_at/status so DEPOIS da confirmacao real do Slack -- nunca antes (missao
// "finalizacao para producao", secao 19).
const guardData = $('Code - Guard de Envio Slack').first().json;
const record = buildEnvioDigestRecord({
reportDate: guardData.dateStr,
runType: guardData.runType,
messageHash: guardData.messageHash,
sentAt: Date.now(),
status: 'success',
});
return [{ json: record }];
@@ -0,0 +1,31 @@
// @requires fallback.js
return $input.all().map((item) => {
const data = item.json;
if (data.resolucao === 'cache' && data.memory) {
const m = data.memory;
const isFallback = m.ai_classification === 'AI_FALLBACK';
return {
json: {
...data,
aiClassification: {
classification: m.ai_classification,
actionable_today: m.ai_classification === 'ACTIONABLE_NOW' || m.ai_classification === 'UNASSIGNED_ACTIONABLE',
confidence: Number(m.ai_confidence || 0),
blocker_type: m.blocker_type || null,
waiting_for: m.waiting_for || null,
next_action: m.next_action || 'Revisar chamado',
suggested_action_type: 'technical_action',
business_risk: 'medium',
reason: m.last_ai_reason || 'Classificacao reaproveitada do cache.',
evidence: [],
// Precisa refletir de verdade o que foi cacheado -- um AI_FALLBACK cacheado
// continua sendo fallback (senao a politica de elegibilidade em cooldown.js
// nao aplicaria a exclusao/override certos numa segunda execucao com cache).
is_fallback: isFallback,
fallback_reason: isFallback ? 'cached_ai_fallback' : null,
},
},
};
}
return { json: { ...data, aiClassification: buildFallbackClassification('ai_max_candidates_cap') } };
});
@@ -0,0 +1,30 @@
// @requires config.js diversity.js
const items = $input.all().map((item) => item.json);
const scored = items.map((data) => ({
ticket: data.ticket,
score: data.scoreResult.score,
group: data.scoreResult.group,
excludedFromTop10: data.scoreResult.excludedFromTop10,
exclusionReason: data.scoreResult.exclusionReason,
decisionReason: data.scoreResult.decisionReason,
aiClassification: data.aiClassification,
memory: data.memory,
contextHash: data.contextHash,
contextChanged: data.contextChanged,
// 'ia' | 'cache' | 'fallback_cap' -- de onde veio a classificacao (Code - Decidir
// Necessidade de IA). Preservado ate aqui pra observabilidade (cache hits x chamadas
// de IA reais), senao se perde depois deste node.
resolucao: data.resolucao,
config: data.config,
now: data.now,
}));
// Instancia bloqueia $env em Code nodes -- config vem do node "Config - Coordenador GLPI v2".
const config = scored.length ? scored[0].config : parseConfig($('Config - Coordenador GLPI v2').first().json);
const { selected, excluded } = selectTop(scored, config.topN, config);
const selectedOut = selected.map((item) => ({ json: { ...item, _grupo: 'selected' } }));
const excludedOut = excluded.map((item) => ({ json: { ...item, _grupo: 'excluded' } }));
return [...selectedOut, ...excludedOut];
@@ -0,0 +1,40 @@
// @requires classification.js fallback.js
const promptItems = $('Code - Preparar Prompt IA').all();
const responseItems = $input.all();
return responseItems.map((responseItem, index) => {
const original = (promptItems[index] && promptItems[index].json) || {};
const httpJson = responseItem.json || {};
if (httpJson.error) {
return { json: { ...original, aiClassification: buildFallbackClassification('ai_error') } };
}
let raw = null;
try {
const content = httpJson.choices && httpJson.choices[0] && httpJson.choices[0].message && httpJson.choices[0].message.content;
raw = content ? JSON.parse(content) : null;
} catch (e) {
raw = null;
}
// Passa o ticket pra validacao poder aplicar a guarda deterministica "tecnico interno nunca
// vira WAITING_VENDOR" (logic/classification.js -> isWaitingForInternalTechnician). Sem
// isso a validacao so olha o JSON cru, nunca os fatos do proprio chamado.
const validation = validateAiClassification(raw, original.ticket);
let aiClassification;
if (validation.valid) {
aiClassification = validation.data;
} else {
// Motivo especifico e auditavel por que a IA caiu pra fallback -- nao joga tudo em
// "invalid_json" (missao "correcao final", secao 9: decision_reason distinguivel).
const isSemanticConflict = validation.errors.some((e) => e.includes('tecnico interno'));
const isGenericAction = validation.errors.some((e) => e.includes('generico demais'));
let reason = 'invalid_json';
if (isSemanticConflict) reason = 'ai_semantic_conflict';
else if (isGenericAction) reason = 'generic_or_unsupported_next_action';
aiClassification = buildFallbackClassification(reason);
}
return { json: { ...original, aiClassification } };
});
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "coordenador-glpi",
"version": "2.0.0-dev",
"private": true,
"description": "Logica deterministica testavel da automacao Coordenador GLPI V2 (n8n).",
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=18"
}
}
+82
View File
@@ -0,0 +1,82 @@
-- Candidatos V2: mesma base/exclusao do V1, sem corte em blocos.
-- Parametros substituidos pelo node: {DIAS_SEM_INTERACAO_ALERTA} {HORAS_SLA_RISCO} {GLPI_BASE_URL} {CANDIDATE_POOL_SIZE}
WITH ultima_interacao AS (
SELECT tickets_id, MAX(data_interacao) AS ultima_interacao_em
FROM (
SELECT tickets_id, MAX(data_interacao) AS data_interacao
FROM (
SELECT items_id AS tickets_id, date AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
UNION ALL
SELECT items_id AS tickets_id, date_mod AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
UNION ALL
SELECT items_id AS tickets_id, date_creation AS data_interacao FROM glpi_itilfollowups WHERE itemtype = 'Ticket' AND is_private = 0
) followup_datas
WHERE data_interacao IS NOT NULL
GROUP BY tickets_id
UNION ALL
SELECT tickets_id, MAX(data_interacao) AS data_interacao
FROM (
SELECT tickets_id, date AS data_interacao FROM glpi_tickettasks
UNION ALL
SELECT tickets_id, date_mod AS data_interacao FROM glpi_tickettasks
UNION ALL
SELECT tickets_id, date_creation AS data_interacao FROM glpi_tickettasks
) task_datas
WHERE data_interacao IS NOT NULL
GROUP BY tickets_id
) interacoes
GROUP BY tickets_id
), tecnicos AS (
SELECT tu.tickets_id,
GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos
FROM glpi_tickets_users tu
LEFT JOIN glpi_users u ON u.id = tu.users_id
WHERE tu.type = 2
GROUP BY tu.tickets_id
), chamados_base AS (
SELECT
t.id, t.name AS titulo, t.content AS descricao, t.status,
CASE t.status WHEN 1 THEN 'Novo' WHEN 2 THEN 'Em atendimento' WHEN 3 THEN 'Planejado' WHEN 4 THEN 'Pendente' ELSE CONCAT('Status ', t.status) END AS status_nome,
t.date AS aberto_em, t.date_mod AS modificado_em, t.time_to_resolve AS sla_resolver_ate,
t.priority, t.urgency, t.impact,
COALESCE(e.completename, e.name, 'Nao informado') AS entidade,
COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,
COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,
GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')) AS ultima_interacao_em,
TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,
GREATEST(TIMESTAMPDIFF(DAY, GREATEST(COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'), COALESCE(t.date_mod, '1970-01-01 00:00:00'), COALESCE(t.date, '1970-01-01 00:00:00')), NOW()), 0) AS dias_sem_interacao,
TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve >= NOW() AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1 ELSE 0 END AS sla_em_risco,
CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,
CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi
FROM glpi_tickets t
LEFT JOIN glpi_entities e ON e.id = t.entities_id
LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id
LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id
LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id
WHERE t.is_deleted = false
AND t.status NOT IN (5, 6)
AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'
AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'
AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'
), chamados_ranked AS (
SELECT
id, titulo, descricao, status, status_nome, aberto_em, modificado_em, ultima_interacao_em,
dias_aberto, dias_sem_interacao, sla_resolver_ate, horas_ate_sla, sla_vencido, sla_em_risco,
priority AS prioridade, urgency AS urgencia, impact AS impacto,
entidade, categoria, tecnico_atribuido, sem_tecnico, link_glpi,
(
CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END
+ CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END
+ CASE WHEN dias_sem_interacao >= 7 THEN 35 WHEN dias_sem_interacao >= 3 THEN 25 WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10 ELSE 0 END
+ LEAST(dias_aberto, 30)
+ (priority * 8) + (urgency * 5) + (impact * 5)
+ CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END
) AS score_criticidade
FROM chamados_base
)
SELECT * FROM chamados_ranked
ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC
LIMIT {CANDIDATE_POOL_SIZE};
+17
View File
@@ -0,0 +1,17 @@
-- Contexto (followups publicos + tarefas) dos candidatos, numa unica query
-- para garantir que o node de merge so roda depois que os dois tipos ja chegaram.
-- IDs interpolados pelo node como lista de inteiros (nunca string crua do GLPI).
-- Coluna de status de glpi_tickettasks (state) a confirmar via DESCRIBE quando a
-- credencial MySQL estiver disponivel (Task 14) -- ver nota no plano.
SELECT 'followup' AS tipo, items_id AS tickets_id, id, date, content AS texto, NULL AS status
FROM glpi_itilfollowups
WHERE itemtype = 'Ticket' AND is_private = 0 AND items_id IN ({IDS})
UNION ALL
SELECT 'task' AS tipo, tickets_id, id, date, content AS texto, state AS status
FROM glpi_tickettasks
WHERE tickets_id IN ({IDS})
ORDER BY tickets_id, date ASC;
@@ -0,0 +1,213 @@
-- Top 10 chamados abertos por blocos operacionais.
-- Blocos:
-- - 3 chamados com SLA vencido mais criticos
-- - 3 chamados com maior tempo sem interacao
-- - 2 chamados sem tecnico atribuido
-- - 2 chamados de prioridade alta mais recentes
-- Se faltar item em algum bloco, o complemento vem do ranking geral.
--
-- Ajuste os parametros abaixo no workflow n8n:
-- {DIAS_SEM_INTERACAO_ALERTA}
-- {HORAS_SLA_RISCO}
-- {GLPI_BASE_URL}
WITH ultima_interacao AS (
SELECT
tickets_id,
MAX(data_interacao) AS ultima_interacao_em
FROM (
SELECT tickets_id, MAX(data_interacao) AS data_interacao
FROM (
SELECT items_id AS tickets_id, date AS data_interacao
FROM glpi_itilfollowups
WHERE itemtype = 'Ticket'
AND is_private = 0
UNION ALL
SELECT items_id AS tickets_id, date_mod AS data_interacao
FROM glpi_itilfollowups
WHERE itemtype = 'Ticket'
AND is_private = 0
UNION ALL
SELECT items_id AS tickets_id, date_creation AS data_interacao
FROM glpi_itilfollowups
WHERE itemtype = 'Ticket'
AND is_private = 0
) followup_datas
WHERE data_interacao IS NOT NULL
GROUP BY tickets_id
UNION ALL
SELECT tickets_id, MAX(data_interacao) AS data_interacao
FROM (
SELECT tickets_id, date AS data_interacao
FROM glpi_tickettasks
UNION ALL
SELECT tickets_id, date_mod AS data_interacao
FROM glpi_tickettasks
UNION ALL
SELECT tickets_id, date_creation AS data_interacao
FROM glpi_tickettasks
) task_datas
WHERE data_interacao IS NOT NULL
GROUP BY tickets_id
) interacoes
GROUP BY tickets_id
), tecnicos AS (
SELECT
tu.tickets_id,
GROUP_CONCAT(TRIM(CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.realname, u.name))) SEPARATOR ', ') AS tecnicos
FROM glpi_tickets_users tu
LEFT JOIN glpi_users u ON u.id = tu.users_id
WHERE tu.type = 2
GROUP BY tu.tickets_id
), chamados_base AS (
SELECT
t.id,
t.name AS titulo,
t.status,
CASE t.status
WHEN 1 THEN 'Novo'
WHEN 2 THEN 'Em atendimento'
WHEN 3 THEN 'Planejado'
WHEN 4 THEN 'Pendente'
ELSE CONCAT('Status ', t.status)
END AS status_nome,
t.date AS aberto_em,
t.date_mod AS modificado_em,
t.time_to_resolve AS sla_resolver_ate,
t.priority,
t.urgency,
t.impact,
COALESCE(e.completename, e.name, 'Nao informado') AS entidade,
COALESCE(c.completename, c.name, 'Sem categoria') AS categoria,
COALESCE(tecnicos.tecnicos, 'Sem tecnico atribuido') AS tecnico_atribuido,
GREATEST(
COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),
COALESCE(t.date_mod, '1970-01-01 00:00:00'),
COALESCE(t.date, '1970-01-01 00:00:00')
) AS ultima_interacao_em,
TIMESTAMPDIFF(DAY, t.date, NOW()) AS dias_aberto,
GREATEST(TIMESTAMPDIFF(DAY, GREATEST(
COALESCE(ultima_interacao.ultima_interacao_em, '1970-01-01 00:00:00'),
COALESCE(t.date_mod, '1970-01-01 00:00:00'),
COALESCE(t.date, '1970-01-01 00:00:00')
), NOW()), 0) AS dias_sem_interacao,
TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) AS horas_ate_sla,
CASE WHEN t.time_to_resolve IS NOT NULL AND t.time_to_resolve < NOW() THEN 1 ELSE 0 END AS sla_vencido,
CASE
WHEN t.time_to_resolve IS NOT NULL
AND t.time_to_resolve >= NOW()
AND TIMESTAMPDIFF(HOUR, NOW(), t.time_to_resolve) <= {HORAS_SLA_RISCO} THEN 1
ELSE 0
END AS sla_em_risco,
CASE WHEN tecnicos.tecnicos IS NULL THEN 1 ELSE 0 END AS sem_tecnico,
CONCAT('{GLPI_BASE_URL}', '/front/ticket.form.php?id=', t.id) AS link_glpi
FROM glpi_tickets t
LEFT JOIN glpi_entities e ON e.id = t.entities_id
LEFT JOIN glpi_itilcategories c ON c.id = t.itilcategories_id
LEFT JOIN ultima_interacao ON ultima_interacao.tickets_id = t.id
LEFT JOIN tecnicos ON tecnicos.tickets_id = t.id
WHERE t.is_deleted = false
AND t.status NOT IN (5, 6)
AND COALESCE(c.completename, c.name, '') NOT LIKE 'ADMINISTRATIVO%'
AND COALESCE(c.completename, c.name, '') NOT LIKE 'DESENVOLVIMENTO%'
AND COALESCE(c.completename, c.name, '') NOT LIKE 'INFRA%'
), chamados_ranked AS (
SELECT
id,
titulo,
status_nome,
aberto_em,
modificado_em,
ultima_interacao_em,
dias_aberto,
dias_sem_interacao,
sla_resolver_ate,
horas_ate_sla,
sla_vencido,
sla_em_risco,
priority AS prioridade,
urgency AS urgencia,
impact AS impacto,
entidade,
categoria,
tecnico_atribuido,
sem_tecnico,
link_glpi,
(
CASE WHEN sla_vencido = 1 THEN 100 ELSE 0 END
+ CASE WHEN sla_em_risco = 1 THEN 45 ELSE 0 END
+ CASE
WHEN dias_sem_interacao >= 7 THEN 35
WHEN dias_sem_interacao >= 3 THEN 25
WHEN dias_sem_interacao >= {DIAS_SEM_INTERACAO_ALERTA} THEN 10
ELSE 0
END
+ LEAST(dias_aberto, 30)
+ (priority * 8)
+ (urgency * 5)
+ (impact * 5)
+ CASE WHEN sem_tecnico = 1 THEN 25 ELSE 0 END
) AS score_criticidade
FROM chamados_base
), sla_top AS (
SELECT *, 'SLA vencido' AS bloco_origem, 1 AS bloco_ordem
FROM chamados_ranked
WHERE sla_vencido = 1
ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
LIMIT 3
), sem_interacao_top AS (
SELECT *, 'Sem interacao' AS bloco_origem, 2 AS bloco_ordem
FROM chamados_ranked
WHERE id NOT IN (SELECT id FROM sla_top)
ORDER BY dias_sem_interacao DESC, score_criticidade DESC, dias_aberto DESC
LIMIT 3
), sem_tecnico_top AS (
SELECT *, 'Sem tecnico' AS bloco_origem, 3 AS bloco_ordem
FROM chamados_ranked
WHERE sem_tecnico = 1
AND id NOT IN (SELECT id FROM sla_top)
AND id NOT IN (SELECT id FROM sem_interacao_top)
ORDER BY score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
LIMIT 2
), prioridade_alta_recente_top AS (
SELECT *, 'Prioridade alta recente' AS bloco_origem, 4 AS bloco_ordem
FROM chamados_ranked
WHERE prioridade >= 4
AND id NOT IN (SELECT id FROM sla_top)
AND id NOT IN (SELECT id FROM sem_interacao_top)
AND id NOT IN (SELECT id FROM sem_tecnico_top)
ORDER BY aberto_em DESC, score_criticidade DESC
LIMIT 2
), selecionados AS (
SELECT * FROM sla_top
UNION ALL
SELECT * FROM sem_interacao_top
UNION ALL
SELECT * FROM sem_tecnico_top
UNION ALL
SELECT * FROM prioridade_alta_recente_top
), complemento AS (
SELECT *, 'Ranking geral' AS bloco_origem, 5 AS bloco_ordem
FROM chamados_ranked
WHERE id NOT IN (SELECT id FROM selecionados)
ORDER BY score_criticidade DESC, sla_vencido DESC, dias_sem_interacao DESC, dias_aberto DESC
LIMIT 10
)
SELECT *
FROM (
SELECT * FROM selecionados
UNION ALL
SELECT * FROM complemento
) ranking_final
ORDER BY bloco_ordem ASC, score_criticidade DESC, dias_sem_interacao DESC, dias_aberto DESC
LIMIT 10;
+63
View File
@@ -0,0 +1,63 @@
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function readLogicSource(fileName) {
const raw = fs.readFileSync(path.join(ROOT, 'logic', fileName), 'utf8');
return raw
// Remove a linha final "module.exports = {...}" -- as funcoes ficam soltas no escopo do node.
.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '')
// Remove requires de outros modulos de logic/*.js -- quando inlined, a funcao requerida
// ja esta no mesmo escopo (senao vira identificador duplicado: function X + const {X} = require(...)).
.replace(/^const\s*\{[^}]*\}\s*=\s*require\(['"]\.\/[a-zA-Z0-9_-]+['"]\);?\s*$/gm, '')
.trim();
}
function inlinedLogic(fileNames) {
return fileNames.map(readLogicSource).join('\n\n');
}
function sqlAsJsTemplateLiteral(sqlRelativePath) {
const sql = fs.readFileSync(path.join(ROOT, sqlRelativePath), 'utf8');
const escaped = sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
return `\`${escaped}\``;
}
function buildDriverCode(driverRelativePath) {
const driverPath = path.join(ROOT, 'n8n', 'nodes', driverRelativePath);
let driver = fs.readFileSync(driverPath, 'utf8');
const requiresMatch = driver.match(/^\/\/ @requires (.+)$/m);
const requiredLogicFiles = requiresMatch ? requiresMatch[1].trim().split(/\s+/) : [];
driver = driver.replace(/^\/\/ @requires .+\n/m, '');
driver = driver.replace('/* __SQL_CANDIDATOS_V2__ */', sqlAsJsTemplateLiteral('sql/candidatos_v2_glpi.sql'));
driver = driver.replace('/* __SQL_LEGADO_V1_VERBATIM__ */', sqlAsJsTemplateLiteral('sql/top_10_chamados_abertos_glpi.sql'));
driver = driver.replace('/* __SQL_CONTEXTO__ */', sqlAsJsTemplateLiteral('sql/contexto_glpi.sql'));
const logicBlock = requiredLogicFiles.length ? `${inlinedLogic(requiredLogicFiles)}\n\n` : '';
return `${logicBlock}${driver.trim()}`;
}
function buildWorkflow() {
const templatePath = path.join(ROOT, 'n8n', 'workflow.template.json');
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
for (const node of template.nodes) {
if (node.type === 'n8n-nodes-base.code' && typeof node.parameters.jsCode === 'string') {
const marker = node.parameters.jsCode.match(/^__BUILD__:(.+)$/);
if (marker) {
node.parameters.jsCode = buildDriverCode(marker[1].trim());
}
}
}
return template;
}
if (require.main === module) {
process.stdout.write(JSON.stringify(buildWorkflow(), null, 2));
}
module.exports = { buildWorkflow, inlinedLogic, readLogicSource, sqlAsJsTemplateLiteral, buildDriverCode };
+276
View File
@@ -0,0 +1,276 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildWorkflow } = require('./build-workflow');
test('workflow montado tem nome e nodes esperados', () => {
const wf = buildWorkflow();
assert.equal(wf.name, '[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)');
const names = wf.nodes.map((n) => n.name);
assert.ok(names.includes('Code - Montar SQL Candidatos'));
});
test('Code - Montar SQL Candidatos tem a logica de config e o SQL inlined (sem marcador de build)', () => {
const wf = buildWorkflow();
const node = wf.nodes.find((n) => n.name === 'Code - Montar SQL Candidatos');
assert.ok(node.parameters.jsCode.includes('function parseConfig'));
assert.ok(node.parameters.jsCode.includes('WITH ultima_interacao'));
assert.ok(!node.parameters.jsCode.includes('__BUILD__'));
assert.ok(!node.parameters.jsCode.includes('__SQL_CANDIDATOS_V2__'));
});
test('nenhum node de Code sobra com marcador nao resolvido', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.ok(!node.parameters.jsCode.includes('__BUILD__'), `node ${node.name} tem marcador nao resolvido`);
}
}
});
test('todo jsCode gerado e sintaticamente valido (pega identificador duplicado, chaves erradas, etc.)', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.doesNotThrow(() => {
// eslint-disable-next-line no-new-func
new Function('$input', '$env', '$', node.parameters.jsCode);
}, `node ${node.name} tem jsCode com erro de sintaxe`);
}
}
});
test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar totalmente inlined)', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.ok(!/\brequire\(/.test(node.parameters.jsCode), `node ${node.name} ainda tem require() nao resolvido`);
}
}
});
test('workflow gerado NAO tem nenhum node de webhook de debug', () => {
const wf = buildWorkflow();
const names = wf.nodes.map((n) => n.name);
assert.ok(!names.includes('Debug - Ver Painel'));
assert.ok(!names.includes('Debug - Responder'));
assert.ok(!names.includes('Code - Montar Painel Debug'));
const webhookNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.webhook');
assert.equal(webhookNodes.length, 0, 'nenhum node webhook deveria sobrar no workflow gerado');
});
test('glpi_base_url no Config nao e mais o placeholder do V1', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const glpiField = configNode.parameters.assignments.assignments.find((a) => a.name === 'glpi_base_url');
assert.notEqual(glpiField.value, 'https://glpi.suaempresa.com.br');
assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br');
});
test('nenhum node chama uma funcao de logic/*.js sem ter a definicao dela inlined (deps transitivas resolvidas em todo @requires)', () => {
// Regressao GENERICA -- ja pegou dois bugs reais nesta missao (normalize.js nao listado
// junto de context.js; hash.js nao listado junto de slack.js). readLogicSource() so inlina
// o que o header @requires de CADA node lista -- nao resolve deps transitivas entre
// logic/*.js sozinho. Um node com a chamada mas sem a definicao compila (sintaxe valida) e
// so explode em runtime com "<funcao> is not defined". Este teste escaneia TODAS as funcoes
// top-level de logic/*.js e garante que, se um node CHAMA uma delas, a DEFINICAO tambem
// esta no mesmo jsCode gerado -- sem precisar listar caso a caso feito antes.
const fs = require('node:fs');
const path = require('node:path');
const logicDir = path.join(__dirname, '..', 'logic');
const functionNames = [];
for (const file of fs.readdirSync(logicDir)) {
if (!file.endsWith('.js') || file.endsWith('.test.js')) continue;
const src = fs.readFileSync(path.join(logicDir, file), 'utf8');
const matches = src.matchAll(/^function\s+(\w+)\s*\(/gm);
for (const m of matches) functionNames.push(m[1]);
}
assert.ok(functionNames.length > 10, 'sanity: deveria ter encontrado varias funcoes em logic/*.js');
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type !== 'n8n-nodes-base.code') continue;
const code = node.parameters.jsCode;
for (const fnName of functionNames) {
const callsIt = new RegExp(`[^.\\w]${fnName}\\(`).test(code);
if (!callsIt) continue;
const definesIt = code.includes(`function ${fnName}`);
assert.ok(
definesIt,
`node "${node.name}" chama ${fnName}(...) mas nao tem "function ${fnName}" inlined -- falta um arquivo de logic/*.js no @requires desse node driver`,
);
}
}
});
test('nenhuma chave de connections fica orfa (nome renomeado sem atualizar connections) -- pegaria o bug real do Schedule Trigger', () => {
const wf = buildWorkflow();
const names = new Set(wf.nodes.map((n) => n.name));
for (const sourceKey of Object.keys(wf.connections)) {
assert.ok(names.has(sourceKey), `connections tem uma chave orfa: "${sourceKey}" nao corresponde a nenhum node`);
}
for (const conn of Object.values(wf.connections)) {
for (const branch of conn.main || []) {
for (const edge of branch || []) {
assert.ok(names.has(edge.node), `connections aponta para um node inexistente: "${edge.node}"`);
}
}
}
});
test('Schedule Trigger esta conectado de verdade ao pipeline (Config) -- teste obrigatorio #17', () => {
const wf = buildWorkflow();
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
assert.ok(scheduleNode, 'workflow deveria ter um Schedule Trigger');
const conn = wf.connections[scheduleNode.name];
assert.ok(conn && conn.main && conn.main[0] && conn.main[0].length > 0, 'Schedule Trigger precisa estar conectado a algum node');
assert.equal(conn.main[0][0].node, 'Config - Coordenador GLPI v2');
});
test('Schedule Trigger roda segunda-sexta as 08h, timezone America/Sao_Paulo -- teste obrigatorio #18', () => {
const wf = buildWorkflow();
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
const cron = scheduleNode.parameters.rule.interval[0].expression;
assert.equal(cron, '0 8 * * 1-5');
assert.equal(wf.settings.timezone, 'America/Sao_Paulo');
});
test('Manual Trigger continua presente para testes/manutencao', () => {
const wf = buildWorkflow();
assert.ok(wf.nodes.some((n) => n.type === 'n8n-nodes-base.manualTrigger'));
});
test('node real de envio Slack existe (nao e so simulado/comentado)', () => {
const wf = buildWorkflow();
const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
assert.ok(slackNode, 'deveria existir um node n8n-nodes-base.slack real');
assert.equal(slackNode.parameters.text, '={{ $json.mensagem }}');
assert.ok(slackNode.credentials && slackNode.credentials.slackApi, 'Slack node deveria usar uma credencial slackApi (nao webhook cru hardcoded)');
assert.equal(slackNode.onError, 'continueErrorOutput', 'precisa separar sucesso/falha pra idempotencia e nao-silenciar-erro');
});
test('Slack node nao anexa o rodape automatico "Automated with this n8n workflow" (bug real: vazou na primeira mensagem real de teste)', () => {
// O node Slack do n8n inclui esse link por padrao (includeLinkToWorkflow default: true) --
// vazou pra dentro do canal real no primeiro teste de envio. A missao pede mensagem minima,
// sem nada alem do que buildTopMessage gera.
const wf = buildWorkflow();
const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
assert.equal(slackNode.parameters.otherOptions.includeLinkToWorkflow, false);
});
test('guard de envio roda antes do Slack e existe IF de decisao', () => {
const wf = buildWorkflow();
const guardConn = wf.connections['Code - Guard de Envio Slack'];
assert.equal(guardConn.main[0][0].node, 'IF - Deve Enviar Slack?');
const ifConn = wf.connections['IF - Deve Enviar Slack?'];
assert.equal(ifConn.main[0][0].node, 'Slack - Enviar Mensagem');
assert.equal(ifConn.main[1][0].node, 'Code - Pular Envio Slack');
});
test('Data Table - Buscar Envios Recentes tem alwaysOutputData=true (bug real: 0 linhas -- caso normal do dia a dia -- parava o branch de envio inteiro)', () => {
// Regressao: rodou de verdade contra o DEV -- a tabela de envios estava vazia (nenhum envio
// ja aconteceu, o caso NORMAL de todo dia), o node "get" devolveu 0 itens, e o n8n nao
// executa nenhum node downstream quando o node anterior produz 0 itens. Isso significa que
// Code - Guard de Envio Slack, o IF, o Slack real, tudo -- nunca rodava em um dia comum.
// alwaysOutputData:true garante pelo menos 1 item de saida mesmo com 0 linhas encontradas.
const wf = buildWorkflow();
const node = wf.nodes.find((n) => n.name === 'Data Table - Buscar Envios Recentes');
assert.equal(node.alwaysOutputData, true, 'sem isso, um dia sem envios anteriores nunca chega a avaliar se deve enviar');
});
test('nodes n8n-nodes-base.dataTable usam valores de operation validos (bug real: "getRows" nao existe, e "get")', () => {
// Regressao: n8n_manage_datatable (a ferramenta MCP) usa a acao "getRows", mas o node
// nativo n8n-nodes-base.dataTable usa o valor de parametro "get" -- nomes parecidos,
// APIs diferentes. Pegou um erro real de validacao ao sincronizar com o DEV live.
const VALID_ROW_OPERATIONS = new Set(['deleteRows', 'get', 'rowExists', 'rowNotExists', 'insert', 'update', 'upsert']);
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type !== 'n8n-nodes-base.dataTable') continue;
const op = node.parameters.operation;
assert.ok(VALID_ROW_OPERATIONS.has(op), `node "${node.name}" tem operation="${op}", que nao e um valor valido do node dataTable`);
}
});
test('todo node dataTable com operation insert/update/upsert tem columns.mappingMode definido (bug real: insert sem columns crasha no n8n com "Cannot convert undefined or null to object")', () => {
// Regressao: rodou de verdade contra o DEV -- os dois nodes novos "Data Table - Registrar
// Envio Digest (Sucesso/Falha)" tinham operation:"insert" e dataTableId, mas nenhum
// "columns" -- que e um campo OBRIGATORIO pra insert/update/upsert (ver get_node
// nodes-base.dataTable). Sem ele, o proprio node do n8n crasha dentro do proprio insert.
const wf = buildWorkflow();
const OPERATIONS_REQUIRING_COLUMNS = new Set(['insert', 'update', 'upsert']);
for (const node of wf.nodes) {
if (node.type !== 'n8n-nodes-base.dataTable') continue;
if (!OPERATIONS_REQUIRING_COLUMNS.has(node.parameters.operation)) continue;
assert.ok(
node.parameters.columns && node.parameters.columns.mappingMode,
`node "${node.name}" tem operation="${node.parameters.operation}" mas nao tem columns.mappingMode -- vai crashar em runtime`,
);
}
});
test('registro de envio (per-ticket) so acontece depois do Data Table de sucesso do Slack -- nunca antes da confirmacao', () => {
const wf = buildWorkflow();
// "Code - Selecionar Top 10" nao pode mais alimentar "Code - Preparar Registro de Envio" direto.
const top10Conn = wf.connections['Code - Selecionar Top 10'];
const directTargets = top10Conn.main[0].map((e) => e.node);
assert.ok(!directTargets.includes('Code - Preparar Registro de Envio'), 'registro de envio nao pode mais sair direto do Selecionar Top 10');
// A cadeia real: Sucesso -> Data Table Sucesso -> Preparar Registro de Envio.
const sucessoDbConn = wf.connections['Data Table - Registrar Envio Digest (Sucesso)'];
assert.equal(sucessoDbConn.main[0][0].node, 'Code - Preparar Registro de Envio');
});
test('falha do Slack sempre grava status=failed antes de lancar o erro (execucao visivelmente falha)', () => {
const wf = buildWorkflow();
const falhaConn = wf.connections['Code - Registrar Envio Falha'];
assert.equal(falhaConn.main[0][0].node, 'Data Table - Registrar Envio Digest (Falha)');
const falhaDbConn = wf.connections['Data Table - Registrar Envio Digest (Falha)'];
assert.equal(falhaDbConn.main[0][0].node, 'Code - Lancar Erro Envio Slack');
const lancarErroNode = wf.nodes.find((n) => n.name === 'Code - Lancar Erro Envio Slack');
assert.ok(lancarErroNode.parameters.jsCode.includes('throw new Error'));
});
test('AI_MAX_CANDIDATES no Config e igual a CANDIDATE_POOL_SIZE (nenhum candidato fica sem analise por cap artificial) -- teste obrigatorio #12', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const assignments = configNode.parameters.assignments.assignments;
const poolSize = assignments.find((a) => a.name === 'CANDIDATE_POOL_SIZE').value;
const maxCandidates = assignments.find((a) => a.name === 'AI_MAX_CANDIDATES').value;
assert.equal(maxCandidates, poolSize);
});
test('Config expoe SEND_ENABLED (default false) e ENVIRONMENT -- teste obrigatorio #13/#22', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const assignments = configNode.parameters.assignments.assignments;
const sendEnabled = assignments.find((a) => a.name === 'SEND_ENABLED');
const environment = assignments.find((a) => a.name === 'ENVIRONMENT');
assert.ok(sendEnabled, 'Config precisa expor SEND_ENABLED');
assert.equal(sendEnabled.value, false, 'DEV deve comecar com SEND_ENABLED=false');
assert.ok(environment, 'Config precisa expor ENVIRONMENT');
});
test('GLPI continua exclusivamente read-only -- nenhum SQL gerado ou arquivo .sql contem INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE -- teste obrigatorio #20', () => {
const fs = require('node:fs');
const path = require('node:path');
const forbidden = /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE)\b/i;
const sqlDir = path.join(__dirname, '..', 'sql');
for (const file of fs.readdirSync(sqlDir)) {
if (!file.endsWith('.sql')) continue;
const content = fs.readFileSync(path.join(sqlDir, file), 'utf8');
assert.ok(!forbidden.test(content), `${file} contem um comando de escrita SQL -- GLPI precisa ser read-only`);
}
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.mySql' && node.parameters && typeof node.parameters.query === 'string') {
assert.ok(!forbidden.test(node.parameters.query), `node MySQL "${node.name}" tem um comando de escrita`);
}
}
});
test('link_glpi na query de candidatos usa a variavel de config, nao um dominio fixo no SQL', () => {
const fs = require('node:fs');
const path = require('node:path');
const sql = fs.readFileSync(path.join(__dirname, '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes("CONCAT('{GLPI_BASE_URL}'"), 'o SQL deve continuar usando o placeholder substituido pelo node, nunca um dominio hardcoded');
assert.ok(!sql.includes('suaempresa.com.br'));
assert.ok(!sql.includes('gtigrupo.com.br'), 'o dominio real deve vir so da config, nunca hardcoded no SQL');
});
+1
View File
@@ -0,0 +1 @@
.env
+127
View File
@@ -0,0 +1,127 @@
# Resumo Operacional GLPI (V2) — em produção
Automação n8n que gera e envia ao Slack um resumo diário/semanal de chamados do GLPI, com números 100% determinísticos (SQL) e uma camada opcional de interpretação por IA (insights, nunca números).
**Status atual: V2 ativada em produção** na instância `devn8n.gtigrupo.com.br` (não existe uma segunda instância n8n de produção — esta é a instância operacional até uma futura migração). V1 é mantida só como referência local (nunca foi implantada nesta instância).
## Princípio central
> **SQL conta. IA entende.**
Todo número no relatório (abertos, fechados, pendentes, saldo, alta prioridade) vem de query SQL determinística. A IA só recebe esses números prontos e uma lista limitada/redigida de chamados, e só pode produzir **insights interpretativos** (texto), nunca alterar ou inventar contagens. Se a IA falhar, atrasar ou responder algo inválido, o relatório continua com fallback determinístico — nunca quebra por causa da IA.
## Arquitetura (fonte de verdade local)
```
src/v2/
config.js — configuração central (thresholds, flags de ambiente)
period.js — calendário operacional e janelas de baseline histórico
queries.js — todas as queries SQL (somente leitura, geradas, nunca hardcoded)
sqlGuard.js — bloqueia qualquer verbo de escrita numa query antes de rodar
baseline.js — pivotamento de séries diárias para detecção de baseline
anomaly.js — detecção determinística de spike/concentração/tendência
insights.js — validação do output da IA, fingerprint, memória de insights
aiSchema.js — contrato JSON schema + prompt da IA + contexto minimizado/redigido
format.js — montagem da mensagem final (fallback e curta)
sendGuard.js — guarda de envio real + idempotência (ver abaixo)
pipeline.js — orquestra tudo (buildReport) — mesma lógica testada localmente
e colada nos Code nodes do n8n (não há import no n8n remoto)
tools/
build_v2_workflow.js — gera os Code nodes a partir de src/v2/*.js
build_v2_workflow_assemble.js — monta o workflow n8n completo (30 nodes)
smoke_test_generated_nodes.js — simula os Code nodes gerados via `vm`, localmente,
antes de qualquer deploy
test/ — 133 testes (`node:test`), cobrindo cada módulo + regressões
estruturais do workflow gerado (executeOnce, sendGuard concatenado)
Resumo Operacional GLPI v2 - IA.n8n.json — workflow gerado (saída de build_v2_workflow.js)
```
**Fluxo de mudança obrigatório:** editar `src/v2/*.js` → `node tools/build_v2_workflow.js` → `npm test` → `node tools/smoke_test_generated_nodes.js` → deploy no n8n (`n8n_update_partial_workflow`/`n8n_update_full_workflow`) → `n8n_validate_workflow`. Nunca editar o Code node direto no n8n sem refletir a mudança em `src/v2/`.
## Guarda de envio real e idempotência (`sendGuard.js`)
```
podeEnviar =
SHADOW_MODE === false (absoluto)
&& DEV_MODE === false (absoluto)
&& SEND_ENABLED === true (absoluto)
&& (isScheduled || FORCE_SEND === true) (manual só envia com override explícito)
```
Identidade de um relatório para fins de duplicata = **`report_type + period_start + period_end + destination`** (sem `message_hash` — a IA não é determinística; incluir o hash na chave já causou envio duplicado real em produção, corrigido em 2026-08-25). O `message_hash` continua calculado e armazenado só para auditoria.
Registro de sucesso na Data Table `dev_glpi_v2_send_log` só acontece **depois** da confirmação real do Slack (`ok:true`). Retry do mesmo período é sempre bloqueado, mesmo que a IA gere texto diferente.
## Configuração operacional atual (node `Config - V2`)
| Flag | Valor | Efeito |
|---|---|---|
| `DEV_MODE` | `false` | produção ativa |
| `SHADOW_MODE` | `false` | envio real habilitado |
| `SEND_ENABLED` | `true` | chave mestra de envio |
| `FORCE_SEND` | `false` (padrão) | execução manual não envia a menos que setado explicitamente |
| `SLACK_DESTINATION` | `C0BJNBTPFT5` | canal onde o bot já é membro confirmado |
| `EXTENDED_METRICS_ENABLED` | `false` | fora de escopo desta versão |
Credenciais reaproveitadas (nenhuma criada nova): MySQL `GLPI` (`dsVj1nmOIXRvix9n`), OpenAI `OpenAI - Coordenador GLPI` (`WUGriAn5FgCiLza2`), Slack `Slack - Bot Coordenador (Chamados Suporte)` (`lNqwBBrtLfkRqw2X`, mesma credencial do workflow irmão "[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)").
## Calendário e agendamento
Schedule Trigger: `0 8 * * 1-5`, timezone da instância (`America/Bahia`, sem conversão manual para UTC).
- Segunda 08:00 → semana anterior completa (segunda a domingo)
- Terça 08:00 → relatório de segunda-feira
- Quarta 08:00 → relatório de terça-feira
- Quinta 08:00 → relatório de quarta-feira
- Sexta 08:00 → relatório de quinta-feira
- `data_alvo` manual (campo em `Config - V2`) força um dia específico, ex.: `2026-08-20`
## GLPI
Acesso **estritamente somente leitura** — `sqlGuard.js` bloqueia qualquer verbo de escrita (`INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE/REPLACE/GRANT/REVOKE`) antes de qualquer query chegar ao node MySQL. Filtro de categorias pelas árvores `7`, `16`, `17`, `18` via `ancestors_cache`.
## Rodando os testes
```powershell
npm test
```
133/133 devem passar. Após qualquer alteração em `src/v2/*.js`, rodar também:
```powershell
node tools/build_v2_workflow.js
node tools/smoke_test_generated_nodes.js
```
## Fallback
- IA indisponível/timeout/JSON inválido → indicadores determinísticos seguem no relatório, insights de IA são omitidos (nunca derruba a automação).
- Baseline histórico indisponível → insight correspondente é omitido, resto do relatório segue normal.
## Workflows V1 (legado, referência local — nunca implantados nesta instância n8n)
Mantidos apenas como referência histórica da lógica SQL original (a V2 herda a mesma lógica de `abertos`/`fechados`/filtro de categorias, validada linha a linha contra a V1):
- `Resumo Diario GLPI - SQL Slack.n8n.json` — lógica de período diário/semanal automático que a V2 herda
- `Resumo Semanal GLPI - SQL Slack.n8n.json`
- `sql/indicadores_diario_glpi.sql`, `sql/indicadores_semana_glpi.sql`
Não editar nem reativar sem necessidade comprovada — a V2 é a versão operacional.
## Subir o N8N localmente com Docker (ambiente de desenvolvimento)
1. Copie `.env.example` para `.env`.
2. Edite `.env` e troque `N8N_BASIC_AUTH_PASSWORD` e `N8N_ENCRYPTION_KEY`.
3. Suba o container:
```powershell
docker compose up -d
```
4. Acesse `http://localhost:5678`.
5. Para parar: `docker compose down`.
Isso é independente da instância operacional (`devn8n.gtigrupo.com.br`), que já está ativada e não depende deste Docker local.
File diff suppressed because one or more lines are too long
@@ -42,6 +42,21 @@ const DEFAULT_CONFIG = {
DEV_MODE: true,
SHADOW_MODE: true, // enquanto true: nunca envia mensagem real (Slack)
// --- Guarda de envio real (secoes 12/13/15 do ciclo final) ---
// Mesmo vocabulario do projeto irmao "[DEV] Coordenador GLPI - Chamados
// Abertos v2 (Shadow)", para nao inventar um padrao novo no ambiente.
// SEND_ENABLED: chave mestra — precisa ser true para QUALQUER envio real
// (scheduled ou manual). FORCE_SEND: so importa em execucao MANUAL —
// destrava o "manual nao envia por padrao"; nunca sobrepoe SHADOW_MODE,
// DEV_MODE ou SEND_ENABLED=false. Ver src/v2/sendGuard.js#canSend.
SEND_ENABLED: true,
FORCE_SEND: false,
// Canal/destino real do Slack (reaproveitado da credencial/canal ja em uso
// no ambiente — ver src/v2/pipeline.js#envio). Vazio ate confirmar com o
// usuario qual canal deve receber o Resumo Diario/Semanal (nao deve ser
// adivinhado: enviar para o canal errado e uma acao irreversivel).
SLACK_DESTINATION: '',
// --- Metricas estendidas (SLA, reabertura, tecnico, etc.) ---
// Desligado por padrao ate o schema real do GLPI DEV ser validado com
// DESCRIBE/SHOW contra o banco (bloqueio: credencial MySQL GLPI - Coordenador).
@@ -21,6 +21,8 @@
* previousPendentes -> pendentes do periodo comparavel anterior (ou null)
* altaPrioridadeAnterior -> alta_prioridade_pendentes do periodo anterior (ou null)
* todayIso -> data de hoje (YYYY-MM-DD), para carimbar a memoria
* triggerType -> 'scheduled' | 'manual' (de $execution.mode no n8n)
* sendLogRows -> linhas atuais da Data Table de log de envios (idempotencia)
*
* aiCall.attempted=false quando AI_ENABLED=false (nunca chamou a IA).
* aiCall.error != null quando a chamada HTTP falhou/deu timeout (n8n
@@ -44,6 +46,7 @@ const {
} = require('./insights');
const { buildTicketContext, parseAiResponse } = require('./aiSchema');
const { formatFallbackMessage, formatShortMessage } = require('./format');
const { canSend, computeMessageHash, buildSendKey } = require('./sendGuard');
function computeEvidenceHash(evidenceTicketIds) {
const ordenado = [...evidenceTicketIds].sort((a, b) => a - b);
@@ -205,6 +208,37 @@ function buildReport(input, config) {
config
);
// --- guarda de envio + idempotencia (secoes 12/13/15 do ciclo final) ---
// triggerType/sendLogRows sao dados de EXECUCAO (nao vem do SQL) —
// fornecidos pelo Code node do n8n a partir de $execution.mode e da
// Data Table de log de envios. Ver src/v2/sendGuard.js.
const messageHash = computeMessageHash(mensagemV2);
// send_key identifica o PERIODO (nao o texto exato — ver comentario em
// sendGuard.js#buildSendKey). message_hash continua calculado e guardado
// a parte, so para diagnostico.
const sendKey = buildSendKey({
reportType: period.history_bucket || period.tipo_periodo,
periodStart: period.data_inicio,
periodEnd: period.data_fim,
destination: config.SLACK_DESTINATION || 'destino_nao_configurado',
});
const guarda = canSend({ triggerType: input.triggerType, config });
const jaEnviado = (input.sendLogRows || []).some(
(r) => r && r.send_key === sendKey && r.status === 'success'
);
const envio = {
trigger_type: input.triggerType || 'desconhecido',
report_type: period.history_bucket || period.tipo_periodo,
period_start: period.data_inicio,
period_end: period.data_fim,
destino: config.SLACK_DESTINATION || null,
send_key: sendKey,
message_hash: messageHash,
pode_enviar: guarda.podeEnviar && !jaEnviado,
motivo_guarda: guarda.motivo,
duplicata_bloqueada: guarda.podeEnviar && jaEnviado,
};
const observabilidade = {
periodo: period.periodo_label,
tipo_periodo: period.tipo_periodo,
@@ -238,6 +272,7 @@ function buildReport(input, config) {
insights_suprimidos: suppressed,
memoria_atualizacoes: [...insightsComEstado.map((i) => i.memoryRow), ...normalizacoes],
anomalias_deterministicas: { spikesCategoria, spikesCliente, concentracaoCliente, tendenciaBacklog },
envio,
observabilidade,
};
}
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/**
* Guarda de envio real (Slack) e idempotencia — secoes 12, 13 e 15 da
* missao original, usando o MESMO vocabulario (SEND_ENABLED/FORCE_SEND)
* ja em uso no projeto irmao "[DEV] Coordenador GLPI - Chamados Abertos
* v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2), para manter consistencia no
* ambiente em vez de inventar um padrao novo.
*
* Regra de decisao (todas as condicoes descritas na missao reduzidas a uma
* formula unica, testada exaustivamente em sendGuard.test.js):
*
* podeEnviar =
* SHADOW_MODE === false (absoluto — nada sobrepoe)
* && DEV_MODE === false (absoluto — nada sobrepoe; enquanto este
* workflow viver em DEV, NUNCA envia)
* && SEND_ENABLED === true (absoluto)
* && (isScheduled || FORCE_SEND === true) (manual so envia com override explicito)
*
* FORCE_SEND so tem efeito na execucao MANUAL (destrava o "manual nao
* envia por padrao"). Ele nunca sobrepoe SHADOW_MODE/DEV_MODE/SEND_ENABLED.
*/
function canSend({ triggerType, config }) {
const isScheduled = triggerType === 'scheduled';
if (config.SHADOW_MODE === true) {
return { podeEnviar: false, motivo: 'SHADOW_MODE=true (absoluto)' };
}
if (config.DEV_MODE === true) {
return { podeEnviar: false, motivo: 'DEV_MODE=true (absoluto, sem override enquanto viver em DEV)' };
}
if (config.SEND_ENABLED !== true) {
return { podeEnviar: false, motivo: 'SEND_ENABLED=false' };
}
if (!isScheduled && config.FORCE_SEND !== true) {
return { podeEnviar: false, motivo: 'execucao manual sem FORCE_SEND=true' };
}
return { podeEnviar: true, motivo: isScheduled ? 'scheduled + guards ok' : 'manual + FORCE_SEND=true' };
}
/**
* Hash estavel (nao criptografico — so precisa ser deterministico e
* compacto) do texto da mensagem, para a chave de idempotencia detectar
* se o CONTEUDO mudou entre duas tentativas do mesmo periodo.
*/
function computeMessageHash(texto) {
const str = String(texto || '');
let hash = 5381;
for (let i = 0; i < str.length; i += 1) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
// >>> 0 converte para unsigned 32-bit antes de exibir em base36 (compacto)
return (hash >>> 0).toString(36);
}
/**
* Chave logica de idempotencia (secao 13): duas execucoes para o MESMO
* report_type + periodo + destino sao o MESMO relatorio logico e NAO devem
* ser enviadas duas vezes, mesmo com retry/execucao manual.
*
* IMPORTANTE (bug real encontrado e corrigido em 2026-08-25, ao vivo, no
* teste controlado de envio): message_hash NAO entra nesta chave. A IA
* (gpt-5.5) nao e deterministica — duas chamadas para o MESMO periodo
* podem gerar insights com titulos/ordem diferentes, mudando o texto final
* e portanto o hash. Incluir message_hash na chave fez o retry do MESMO
* periodo passar como "relatorio diferente" e enviar DUAS mensagens reais
* ao Slack (execucoes 16744 e 16746). A identidade do relatorio e o
* PERIODO, nao o texto exato — o hash continua sendo calculado e guardado
* (campo message_hash) so para diagnostico/auditoria, nunca para decidir
* duplicata.
*/
function buildSendKey({ reportType, periodStart, periodEnd, destination }) {
return [reportType, periodStart, periodEnd, destination].join('|');
}
/**
* Verifica se ja existe um envio com status=success para a mesma chave
* logica (report_type + period + destination — ver buildSendKey).
*/
function isDuplicate(sendLogRows, key) {
return (sendLogRows || []).some(
(row) => row && row.send_key === key && row.status === 'success'
);
}
module.exports = { canSend, computeMessageHash, buildSendKey, isDuplicate };

Some files were not shown because too many files have changed in this diff Show More