Redator de SD - preparado para deploy
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Fora do contexto de build (velocidade e segurança)
|
||||
app/node_modules
|
||||
app/data
|
||||
app/.env
|
||||
app/.env.example
|
||||
*.zip
|
||||
SD/
|
||||
prototipo-criador-de-demanda.html
|
||||
@@ -0,0 +1,7 @@
|
||||
# Segredos e dados locais — NUNCA versionar
|
||||
app/node_modules/
|
||||
app/.env
|
||||
app/data/
|
||||
|
||||
# Arquivos soltos que não pertencem ao deploy
|
||||
*.zip
|
||||
@@ -0,0 +1,89 @@
|
||||
# Deploy no Coolify — Redator de SD
|
||||
|
||||
Método escolhido: **Dockerfile** (já pronto na raiz do projeto). É a melhor opção aqui porque
|
||||
o projeto tem dois níveis — os arquivos de configuração do redator na raiz e o app em `app/` —
|
||||
e o Nixpacks não lida bem com isso (detectaria o Node em `app/` e deixaria de fora
|
||||
`regras-sd.json`, `schema-sd.json`, o prompt e a base). Docker Compose não acrescenta nada:
|
||||
é um serviço só, e volumes/variáveis o painel do Coolify resolve.
|
||||
|
||||
## Passo 0 — subir o projeto para um repositório Git PRIVADO
|
||||
|
||||
O Coolify puxa o código de um repositório. Na pasta `D:\IasisSaude`:
|
||||
|
||||
```bash
|
||||
git init -b main
|
||||
git add .
|
||||
git commit -m "Redator de SD - preparado para deploy"
|
||||
```
|
||||
|
||||
Crie um repositório **privado** no GitHub (ex.: `iasis/redator-sd`) e:
|
||||
|
||||
```bash
|
||||
git remote add origin https://github.com/SEU-USUARIO/redator-sd.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
O `.gitignore` da raiz já bloqueia `app/.env` (chave de API!), `app/data/` e `node_modules`.
|
||||
|
||||
## Passo 1 — criar a aplicação no painel
|
||||
|
||||
1. **Projects → seu projeto → + New → Application**.
|
||||
2. Fonte: **Private Repository (with GitHub App)** → conecte a conta na primeira vez →
|
||||
selecione o repositório e o branch `main`.
|
||||
3. Em **Build Pack**, escolha **Dockerfile** (ele acha o `Dockerfile` na raiz sozinho).
|
||||
4. Em **Ports Exposes**: `3344`.
|
||||
|
||||
## Passo 2 — variáveis de ambiente (aba Environment Variables)
|
||||
|
||||
| Variável | Valor |
|
||||
|---|---|
|
||||
| `ANTHROPIC_API_KEY` | a chave (marque como **secret**) |
|
||||
| `APP_USUARIO` | usuário do login — **obrigatório na internet** |
|
||||
| `APP_SENHA` | senha FORTE — é a única porta de entrada |
|
||||
| `LLM_MODEL` | `claude-opus-5` (ou o padrão que preferir) |
|
||||
| `LLM_MODELOS` | `claude-sonnet-5,claude-haiku-4-5-20251001` |
|
||||
| `LLM_EFFORT` | `medium` |
|
||||
|
||||
`PORT` não precisa: o padrão 3344 casa com o EXPOSE da imagem.
|
||||
|
||||
## Passo 3 — persistência (aba Storages) — NÃO PULE
|
||||
|
||||
Sem isto, **cada deploy apaga as conversas e o histórico da base**:
|
||||
|
||||
1. **+ Add → Volume Mount**
|
||||
- Destination Path: `/projeto/app/data`
|
||||
- (guarda `sds.json`: conversas e rascunhos)
|
||||
2. **+ Add → File Mount**
|
||||
- Destination Path: `/projeto/base-conhecimento.json`
|
||||
- Content: cole o conteúdo ATUAL do seu `D:\IasisSaude\base-conhecimento.json`
|
||||
- (o app escreve neste arquivo ao enviar SDs e alimentar a base; o file mount
|
||||
sobrevive aos deploys)
|
||||
|
||||
⚠ A partir do primeiro uso em produção, a base **da VPS** vira a oficial — o arquivo
|
||||
local no seu PC deixa de valer. Para trazer de volta, copie o conteúdo pela aba
|
||||
Storages (ou `cat` no terminal do container).
|
||||
|
||||
## Passo 4 — domínio e HTTPS
|
||||
|
||||
1. No seu DNS, crie um registro **A** apontando (ex.: `redator.iasis.tech`) para o IP da VPS.
|
||||
2. Na aplicação, em **Domains**: `https://redator.iasis.tech`.
|
||||
3. O Coolify (Traefik) emite o certificado Let's Encrypt sozinho.
|
||||
|
||||
## Passo 5 — health check (opcional, recomendado)
|
||||
|
||||
Aba **Health Check**: Path `/api/sessao`, Port `3344` — responde 200 sem login.
|
||||
|
||||
## Passo 6 — Deploy
|
||||
|
||||
Botão **Deploy** e acompanhe os logs até aparecer:
|
||||
`Redator de SD rodando em http://localhost:3344`.
|
||||
|
||||
Atualizações futuras: `git add … && git commit && git push` — com **Auto Deploy**
|
||||
ligado (padrão via webhook do GitHub App), o Coolify rebuilda sozinho.
|
||||
|
||||
## Comportamentos esperados em produção
|
||||
|
||||
- **Sessões em memória**: a cada deploy/restart, é preciso logar de novo.
|
||||
- **Upload de PDF** (Base de Conhecimento): até 15 MB — passa pelo Traefik sem ajuste.
|
||||
- **Editar prompt/regras/schema**: edite no repositório e faça push (vira deploy);
|
||||
a base de conhecimento NÃO — ela vive no file mount e é editada pelo próprio app.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Redator de SD — RMDS / SES-MG × Isis Saúde
|
||||
# Imagem para deploy (no Coolify: Build Pack = Dockerfile, porta 3344).
|
||||
# A estrutura do container espelha o projeto: arquivos de configuração do
|
||||
# redator na raiz (/projeto) e o app em /projeto/app — igual ao disco local.
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /projeto
|
||||
|
||||
# Dependências primeiro, para aproveitar o cache entre builds
|
||||
COPY app/package.json app/package-lock.json ./app/
|
||||
RUN cd app && npm ci --omit=dev
|
||||
|
||||
# Arquivos de configuração do redator (fonte única — raiz do projeto).
|
||||
# base-conhecimento.json entra como semente: em produção ele é sobreposto
|
||||
# pelo file mount persistente do Coolify (senão cada deploy apagaria o histórico).
|
||||
COPY prompt-redator-sd.md regras-sd.json schema-sd.json base-conhecimento.json ./
|
||||
|
||||
# Código do app (o .env NUNCA entra na imagem — variáveis vêm do painel do Coolify)
|
||||
COPY app/src ./app/src
|
||||
COPY app/public ./app/public
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3344
|
||||
|
||||
WORKDIR /projeto/app
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -0,0 +1,401 @@
|
||||
# Manual de Redação de Solicitação de Demanda (SD)
|
||||
|
||||
## Como transformar uma demanda em entregáveis faturáveis, enquadrados e datados
|
||||
|
||||
Contrato: Pregão Eletrônico nº 29/2025 — Processo 1321127 · SES-MG × Isis Saúde
|
||||
|
||||
---
|
||||
|
||||
## A regra que organiza tudo
|
||||
|
||||
**O entregável é a unidade faturável.** Ele carrega tipo, item, semanas, USTs e datas próprias. É por ele que se mede, se fatura e se controla saldo de pool.
|
||||
|
||||
A fase — Descoberta, Design, Arquitetura, Construção — **não é um container**. É um atributo do entregável, e o que define seu time-box.
|
||||
|
||||
```
|
||||
ENTREGÁVEL
|
||||
├── Tipo (Descoberta / Design / Arquitetura / Construção) → define o time-box
|
||||
├── Item do TR (I-02 / I-03 / I-04 / I-05) → define de qual pool debita
|
||||
├── Semanas (2 a 4) → define a duração
|
||||
├── USTs = time-box × semanas → o valor faturável
|
||||
├── Período (data inicial a data final) → quando aconteceu
|
||||
├── Artefatos comprobatórios → o que prova
|
||||
└── Critérios de aceite → como se verifica
|
||||
```
|
||||
|
||||
Duas consequências práticas:
|
||||
|
||||
- **Uma SD pode ter dois entregáveis do mesmo tipo**, com escopos e datas diferentes. Não existe "a fase de Construção" — existem os entregáveis de Construção que a demanda exigiu.
|
||||
- **Entregáveis de itens diferentes nunca se somam na mesma linha.** Cada um debita de um pool distinto e é medido separadamente.
|
||||
|
||||
---
|
||||
|
||||
# PARTE I — MONTAGEM DOS ENTREGÁVEIS
|
||||
|
||||
---
|
||||
|
||||
## Passo 1 — Entenda a demanda
|
||||
|
||||
Três perguntas:
|
||||
|
||||
**1. O que o cliente quer ver funcionando no final?**
|
||||
Não o que precisa ser feito — o que ele quer *ver*.
|
||||
|
||||
**2. Isso já existe ou é novo?**
|
||||
Já existe e precisa mudar → manutenção. Nunca existiu → construção nova.
|
||||
|
||||
**3. Qual é a natureza do trabalho?**
|
||||
Construir um pedaço da plataforma? Automatizar um processo? Criar um dashboard? Treinar alguém?
|
||||
|
||||
Demanda com mais de uma natureza cruza mais de um item. Isso é normal — vira entregáveis separados.
|
||||
|
||||
---
|
||||
|
||||
## Passo 2 — Decomponha em atividades
|
||||
|
||||
Blocos de trabalho que uma pessoa consiga explicar em uma frase. Não precisa ser granular.
|
||||
|
||||
---
|
||||
|
||||
## Passo 3 — Enquadre cada atividade
|
||||
|
||||
```
|
||||
1. É plataforma crescendo (módulo novo, fonte nova, conector novo)? → I-02
|
||||
2. É ensinar alguém a usar algo que já foi implantado? → I-03
|
||||
3. É automatizar tarefa repetitiva / corrigir algo em produção? → I-04
|
||||
4. É criar dashboard, algoritmo, IA, busca ativa, análise? → I-05
|
||||
```
|
||||
|
||||
**Teste da estrada** — para a dúvida clássica entre I-02 e I-04:
|
||||
|
||||
> I-02 constrói a estrada. I-04 põe o piloto automático.
|
||||
> Primeira vez que conecta ou constrói → I-02.
|
||||
> Automatizar algo que já poderia ser feito manualmente → I-04.
|
||||
|
||||
**Armadilhas frequentes:**
|
||||
|
||||
| Situação | Item correto | Por quê |
|
||||
|---|---|---|
|
||||
| Produto novo sendo construído | I-02 | Ainda não existe para ser mantido |
|
||||
| Dashboard, índice, score, ranking | I-05 | É inteligência, não automação |
|
||||
| App em produção com bug de interface | I-04 | Manutenção perfectiva |
|
||||
| Regra de negócio nova no motor | I-05 | Evolução de algoritmo |
|
||||
| Oficina de discovery pré-implantação | I-05 | I-03 é consultoria pós-implantação |
|
||||
|
||||
---
|
||||
|
||||
## Passo 4 — Agrupe as atividades em entregáveis
|
||||
|
||||
Agrupe atividades que compartilham **o mesmo item e o mesmo tipo** e que produzem um artefato final coerente.
|
||||
|
||||
| Tipo | O que entra | Pergunta-guia |
|
||||
|---|---|---|
|
||||
| **Descoberta** | Levantamento, mapeamento, investigação | "O que eu precisava saber antes de começar?" |
|
||||
| **Design** | Visualização, UX, fluxos, protótipos, modelagem de regras | "Como vai parecer?" |
|
||||
| **Arquitetura** | Planejamento técnico, modelo de dados, viabilidade | "Como vai ser construído?" |
|
||||
| **Construção** | Implementação, teste, publicação | "Está funcionando?" |
|
||||
|
||||
**Quando quebrar em mais de um entregável do mesmo tipo:**
|
||||
|
||||
- O escopo excede 4 semanas → quebre, porque 4 é o teto por entregável
|
||||
- Duas frentes rodam em paralelo com times distintos → entregáveis separados, mesmo período
|
||||
- Um pedaço pode ser faturado antes do outro → separe, para não travar o faturamento do que já está pronto
|
||||
- Atividades pertencem a itens diferentes → obrigatoriamente separados
|
||||
|
||||
**Quando não quebrar:**
|
||||
|
||||
- Só para "ter mais entregáveis". Cada entregável custa USTs próprias — quebrar infla o orçamento sem entregar mais
|
||||
- Quando o resultado só faz sentido inteiro. Metade de um dashboard não é faturável
|
||||
|
||||
Nem toda demanda tem os quatro tipos. Uma oficina pode ter só Descoberta e Construção. Uma manutenção, só Construção. Tipo inventado para "ficar completo" é glosa esperando acontecer.
|
||||
|
||||
---
|
||||
|
||||
## Passo 5 — Defina semanas e datas de cada entregável
|
||||
|
||||
```
|
||||
Mínimo: 2 semanas (regra contratual)
|
||||
Máximo: 4 semanas
|
||||
Típico: 2 a 3 semanas
|
||||
```
|
||||
|
||||
| Se o entregável... | Semanas |
|
||||
|---|---|
|
||||
| É simples, escopo fechado, poucas dependências | 2 |
|
||||
| É médio, precisa de reuniões e validação | 3 |
|
||||
| É complexo, múltiplos stakeholders, muita incerteza | 4 |
|
||||
|
||||
**Regras de data:**
|
||||
|
||||
- Entregáveis **encadeados** (um depende do outro) não podem se sobrepor. Se Descoberta e Design compartilham semanas, os mesmos perfis aparecem alocados a 200% — é achado de auditoria.
|
||||
- Entregáveis **paralelos** podem compartilhar período, desde que sejam times distintos. Declare isso explicitamente na SD, ou parecerá erro.
|
||||
- A data final de cada entregável é a data que habilita seu faturamento. Ela precisa fazer sentido contra a evidência: entregável que fecha em 31/05 não pode ter como comprovação um documento enviado em 14/06.
|
||||
|
||||
---
|
||||
|
||||
## Passo 6 — Calcule as USTs de cada entregável
|
||||
|
||||
```
|
||||
USTs = time-box × semanas
|
||||
```
|
||||
|
||||
**Time-box por tipo e item:**
|
||||
|
||||
| | Descoberta | Design | Arquitetura | Construção |
|
||||
|---|---|---|---|---|
|
||||
| **I-02** | 40h | 40h | 40h | **40h** |
|
||||
| **I-03** | 40h | 40h | 40h | **40h** |
|
||||
| **I-04** | 40h | 40h | 40h | **80h** |
|
||||
| **I-05** | 40h | 40h | 40h | **80h** |
|
||||
|
||||
Descoberta, Design e Arquitetura são sempre 40h. A única diferença está na Construção: 40h em I-02/I-03, 80h em I-04/I-05.
|
||||
|
||||
**USTs prontas para copiar:**
|
||||
|
||||
| Semanas | Desc / Design / Arq (todos os itens) | Construção I-02 e I-03 | Construção I-04 e I-05 |
|
||||
|---|---|---|---|
|
||||
| 2 | 80 | 80 | **160** |
|
||||
| 3 | 120 | 120 | **240** |
|
||||
| 4 | 160 | 160 | **320** |
|
||||
|
||||
---
|
||||
|
||||
## Passo 7 — Defina os artefatos comprobatórios de cada entregável
|
||||
|
||||
**Entregável ≠ artefato.** O entregável é a unidade faturável. Os artefatos são os documentos que provam que ele aconteceu.
|
||||
|
||||
Quanto maior o valor do entregável, mais lastro documental ele precisa carregar:
|
||||
|
||||
| USTs do entregável | Artefatos comprobatórios mínimos |
|
||||
|---|---|
|
||||
| 80 | 1 |
|
||||
| 120 a 160 | 2 |
|
||||
| 240 ou mais | 3 |
|
||||
|
||||
Nomeie artefatos que **existem de fato**. Documento comprobatório inventado é pior que entregável sem comprovação — o primeiro é problema de integridade, o segundo é só pendência.
|
||||
|
||||
---
|
||||
|
||||
# PARTE II — REDAÇÃO DA SD
|
||||
|
||||
---
|
||||
|
||||
## Cabeçalho
|
||||
|
||||
| Campo | Regra de preenchimento |
|
||||
|---|---|
|
||||
| Cliente / Contratante | Secretaria de Estado de Saúde de Minas Gerais (SES-MG) |
|
||||
| Contratada | Isis Saúde |
|
||||
| Contrato | Pregão Eletrônico nº 29/2025 — Processo 1321127 |
|
||||
| Projeto / Solução | Nome do projeto real desta demanda. **Confira** — é o campo mais copiado por engano |
|
||||
| Nº da SD | Sequencial |
|
||||
| Versão | V1, V2… |
|
||||
| Data de abertura | DD/MM/AAAA. Nunca escreva "não formalizada" nem qualquer confissão de irregularidade |
|
||||
| Período de execução | Da data inicial do primeiro entregável à data final do último |
|
||||
| Item ou itens do TR | Número **e** título literal. Se a SD cruza itens, liste todos |
|
||||
| Quantidade total de UST | Soma dos entregáveis, com número por extenso |
|
||||
| Prazo de execução | Duração do calendário, não a soma das semanas quando há paralelismo |
|
||||
| Quantidade de entregáveis | Número |
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Dois a quatro períodos. Diz o que a SD formaliza e qual resultado permite alcançar. Não repete o contexto.
|
||||
|
||||
---
|
||||
|
||||
## 2. Contexto e escopo da demanda
|
||||
|
||||
Três a cinco parágrafos:
|
||||
|
||||
1. **O problema de gestão** — o que trava, o que a SES-MG não consegue fazer hoje
|
||||
2. **O insumo disponível** — o que já existe e por que não basta na forma atual
|
||||
3. **O que a demanda entrega** — a solução em termos concretos
|
||||
4. **O achado ou resultado**, quando houver
|
||||
5. **O desdobramento** — o que isso destrava adiante
|
||||
|
||||
Números reais e verificáveis. Se um número aparece na SD e outro no artefato entregue, a inconsistência aparece sozinha na primeira leitura cruzada.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quadro de entregáveis
|
||||
|
||||
O coração da SD. Uma linha por entregável faturável.
|
||||
|
||||
| # | Entregável | Tipo | Item TR | Semanas | Time-box | UST | Período |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | | | | | | | |
|
||||
| 2 | | | | | | | |
|
||||
| 3 | | | | | | | |
|
||||
| | **Total** | | | | | **XXX** | |
|
||||
|
||||
**Confira antes de seguir:**
|
||||
- Cada linha: `UST = time-box × semanas`
|
||||
- Nenhum entregável abaixo de 2 nem acima de 4 semanas
|
||||
- Time-box de Construção correto para o item (40h ou 80h)
|
||||
- Períodos de entregáveis encadeados não se sobrepõem
|
||||
- Se há paralelismo, está declarado em nota
|
||||
- A soma da coluna UST bate com o cabeçalho
|
||||
|
||||
Quando a SD cruza itens, acrescente uma linha de subtotal por item — é assim que o controle de pool é feito.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detalhamento dos entregáveis
|
||||
|
||||
Um bloco por entregável, na ordem do quadro. Quatro seções cada.
|
||||
|
||||
```
|
||||
ENTREGÁVEL N — [Nome do artefato final]
|
||||
Tipo · Item TR · X semanas · XX UST · DD/MM a DD/MM
|
||||
|
||||
Resumo
|
||||
Três linhas. O que o entregável fez e o que produziu.
|
||||
|
||||
Atividades previstas
|
||||
Bullets. Blocos de trabalho concretos, de seis a nove.
|
||||
|
||||
Artefatos comprobatórios
|
||||
- [Documento com nome real e, quando houver, data]
|
||||
- [Documento]
|
||||
|
||||
Critérios de aceite
|
||||
Bullets. Condições verificáveis por terceiro.
|
||||
```
|
||||
|
||||
**Sobre os critérios de aceite** — o teste é se alguém que não participou consegue dizer sim ou não olhando o artefato:
|
||||
|
||||
| Serve | Não serve |
|
||||
|---|---|
|
||||
| "Índice reproduzível: mesma base e mesmo código produzem resultado idêntico" | "Modelo bem construído" |
|
||||
| "Dashboard acessível pelos perfis indicados pela SES-MG" | "Dashboard funcionando adequadamente" |
|
||||
| "Toda pergunta do formulário mapeada à dimensão que alimenta" | "Documentação completa" |
|
||||
|
||||
---
|
||||
|
||||
## 5. Enquadramento no Termo de Referência
|
||||
|
||||
Seção obrigatória. É a que sustenta a SD se a fiscalização contestar o item — e é a que evita a reclassificação que muda o time-box de Construção e derruba as USTs pela metade.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Item do TR** | Número e título literal, exatamente como no TR |
|
||||
| **Descrição do item** | Transcrição do escopo do item conforme o TR |
|
||||
| **Aderência desta SD** | Bullets ligando o que foi entregue ao que o item prevê |
|
||||
|
||||
**Regras para os bullets de aderência:**
|
||||
|
||||
- Um bullet por componente entregue, de três a cinco no total
|
||||
- Use o vocabulário do item, não o vocabulário interno da equipe
|
||||
- Descreva o que foi construído, não o que foi discutido
|
||||
- Não cite componente que a SD não tem só para reforçar o encaixe
|
||||
|
||||
**Exemplo — entregáveis de índice e dashboard (I-05):**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Item do TR** | I-05 – Serviços técnicos de inteligência e produtos analíticos de gestão em saúde |
|
||||
| **Descrição do item** | Serviços técnicos de inteligência aplicada aos dados da plataforma, compreendendo a concepção e o desenvolvimento de algoritmos analíticos, modelos de classificação, índices e dashboards gerenciais destinados a apoiar a tomada de decisão das áreas demandantes da SES-MG. Inclui extração de fenótipos, clusterização e priorização de populações e territórios, apuração de indicadores e disponibilização dos resultados em painéis, relatórios e produtos analíticos de gestão. |
|
||||
| **Aderência desta SD** | • Concepção do algoritmo de classificação em sete dimensões ponderadas, com penalidade, bônus de cadeia e normalização em escala de 0 a 100.<br>• Desenvolvimento do motor de cálculo que processa as respostas do formulário e atribui score e faixa a cada município.<br>• Construção do dashboard gerencial, com ranking, cadeia de envios por domínio e corte por macrorregião.<br>• Disponibilização do painel aos gestores da SES-MG como insumo de priorização das ondas de integração. |
|
||||
|
||||
**Quando a SD cruza dois itens:** repita a seção 5 inteira para cada item, e indique quais entregáveis do quadro pertencem a cada um.
|
||||
|
||||
---
|
||||
|
||||
# PARTE III — VALIDAÇÃO
|
||||
|
||||
**Enquadramento**
|
||||
```
|
||||
[ ] Cada entregável tem item definido (I-02 / I-03 / I-04 / I-05)
|
||||
[ ] Produto novo está no I-02, não no I-04
|
||||
[ ] Dashboard, índice ou algoritmo está no I-05, não no I-04
|
||||
[ ] Automação de tarefa repetitiva está no I-04
|
||||
[ ] Treinamento pós-implantação está no I-03
|
||||
[ ] Entregáveis de itens diferentes estão em linhas separadas
|
||||
[ ] Há subtotal por item quando a SD cruza itens
|
||||
```
|
||||
|
||||
**Cálculo**
|
||||
```
|
||||
[ ] Cada entregável entre 2 e 4 semanas
|
||||
[ ] UST de cada linha = time-box × semanas
|
||||
[ ] Time-box de Construção correto (40h em I-02/I-03, 80h em I-04/I-05)
|
||||
[ ] Soma da coluna UST bate com o cabeçalho
|
||||
[ ] Entregáveis encadeados sem sobreposição de período
|
||||
[ ] Paralelismo, quando existe, está declarado
|
||||
[ ] Prazo do cabeçalho reflete o calendário, não a soma das semanas
|
||||
```
|
||||
|
||||
**Documento**
|
||||
```
|
||||
[ ] Nome do projeto é o desta demanda, não copiado de outra SD
|
||||
[ ] Número do item e título do item correspondem entre si
|
||||
[ ] Título do item confere com a redação literal do TR
|
||||
[ ] Números do contexto batem com os artefatos entregues
|
||||
[ ] Produto tem um nome só ao longo de todo o documento
|
||||
[ ] Nenhum campo confessa irregularidade
|
||||
[ ] Cada entregável tem o mínimo de artefatos comprobatórios da sua faixa
|
||||
[ ] Todo artefato citado existe de fato
|
||||
[ ] Data final de cada entregável é coerente com a data dos seus artefatos
|
||||
[ ] Todo critério de aceite é verificável por terceiro
|
||||
[ ] Seção 5 preenchida com aderência específica, não genérica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ANEXO — Referência rápida
|
||||
|
||||
## Enquadramento em 4 perguntas
|
||||
|
||||
```
|
||||
1. É plataforma crescendo (módulo novo, fonte nova, conector novo)? → I-02
|
||||
2. É ensinar alguém a usar algo que já foi implantado? → I-03
|
||||
3. É automatizar tarefa repetitiva / corrigir algo em produção? → I-04
|
||||
4. É criar dashboard, algoritmo, IA, busca ativa, análise? → I-05
|
||||
```
|
||||
|
||||
## USTs por entregável
|
||||
|
||||
| Semanas | Desc / Design / Arq | Construção I-02 e I-03 | Construção I-04 e I-05 |
|
||||
|---|---|---|---|
|
||||
| 2 | 80 | 80 | 160 |
|
||||
| 3 | 120 | 120 | 240 |
|
||||
| 4 | 160 | 160 | 320 |
|
||||
|
||||
## Artefatos comprobatórios por entregável
|
||||
|
||||
| USTs | Artefatos mínimos |
|
||||
|---|---|
|
||||
| 80 | 1 |
|
||||
| 120 a 160 | 2 |
|
||||
| 240+ | 3 |
|
||||
|
||||
## Conjunto típico de entregáveis (3 + 2 + 2 + 3 semanas)
|
||||
|
||||
| Item | Descoberta | Design | Arquitetura | Construção | **Total** |
|
||||
|---|---|---|---|---|---|
|
||||
| I-02 | 120 | 80 | 80 | 120 | **400** |
|
||||
| I-03 | 120 | 80 | 80 | 120 | **400** |
|
||||
| I-04 | 120 | 80 | 80 | 240 | **520** |
|
||||
| I-05 | 120 | 80 | 80 | 240 | **520** |
|
||||
|
||||
## Estrutura da SD
|
||||
|
||||
```
|
||||
Cabeçalho (identificação)
|
||||
1. Objetivo
|
||||
2. Contexto e escopo da demanda
|
||||
3. Quadro de entregáveis (tipo × item × semanas × time-box × UST × período)
|
||||
4. Detalhamento dos entregáveis
|
||||
4.N Resumo (3 linhas)
|
||||
4.N Atividades previstas (bullets)
|
||||
4.N Artefatos comprobatórios (nomeados)
|
||||
4.N Critérios de aceite (bullets verificáveis)
|
||||
5. Enquadramento no Termo de Referência
|
||||
Item do TR / Descrição do item / Aderência desta SD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Documento de apoio à formalização de demandas — RMDS / SES-MG.*
|
||||
@@ -0,0 +1,254 @@
|
||||
**SOLICITAÇÃO DE DEMANDA (SD)**
|
||||
|
||||
**MVP – Vacina em Dia**
|
||||
|
||||
*Caderneta Vacinal Digital no MG App com Dados do PNI e Alertas de Pendências*
|
||||
|
||||
| Cliente / Contratante | Secretaria de Estado de Saúde de Minas Gerais (SES-MG) |
|
||||
| :---- | :---- |
|
||||
| **Contratada** | Isis Saúde |
|
||||
| **Projeto / Solução** | P2 \- Vacinação |
|
||||
| **Nº da SD** | SD8 |
|
||||
| **Versão** | V1 |
|
||||
| **Data de Abertura** | 01/05/2026 |
|
||||
| **Item do Termo de Referência** | I-02 e I-05 |
|
||||
| **Quantidade de UST Estimadas** | 1120 (mil cento e vinte) UST |
|
||||
| **Prazo de Execução Estimado** | 5 (cinco) semanas |
|
||||
| **Sprints Contempladas** | Descoberta, Design, Arquitetura e Construção |
|
||||
|
||||
# **1\. Objetivo**
|
||||
|
||||
Entregar ao cidadão mineiro a caderneta vacinal digital: histórico de imunização unificado a partir da RNDS, com identificação por CNS/CPF e visão longitudinal por pessoa.
|
||||
|
||||
# **2\. Justificativa**
|
||||
|
||||
O histórico vacinal do cidadão está fragmentado entre municípios, sistemas e cadernetas de papel — não existe visão única nem para o cidadão nem para o gestor. Esta SD é a primeira prova pública da RMDS: demonstra que a plataforma unifica identidade e linha do tempo do cidadão, e cria a porta de entrada de relacionamento com a população.
|
||||
|
||||
# **3\. Contexto**
|
||||
|
||||
O projeto Vacina em Dia consiste no desenvolvimento e implementação de um ecossistema digital inteligente voltado à gestão da saúde vacinal do cidadão. A demanda origina-se da necessidade estrita da Secretaria de Estado de Saúde de Minas Gerais (SES-MG) de prover transparência, acessibilidade e resolutividade clínica à população mineira diretamente por meio do MG App (o super-app de serviços públicos do Estado).
|
||||
|
||||
O motor opera de forma determinística, cruzando ativamente o histórico vacinal em tempo real do indivíduo (extraído via integração escalável com a base nacional do PNI/RNDS) com as diretrizes normativas vigentes do Programa Nacional de Imunizações (PNI 2026), do Manual de Centros de Referência para Imunobiológicos Especiais (CRIE) e das resoluções técnicas estaduais da SES-MG
|
||||
|
||||
O desenvolvimento e a homologação do sistema submeteram-se a uma restrição temporal:
|
||||
|
||||
* Janela de Execução: O projeto foi planejado e codificado em um ciclo ágil e ininterrupto de 02/06/2026 a 03/07/2026
|
||||
* Marco de Publicação Impositiva: A disponibilização final em produção ocorreu em 02 de julho de 2026, às 11h00, cumprindo a obrigatoriedade institucional de entrar em operação pelo menos 24 horas antes do início do período de vedação eleitoral (estabelecido em 04 de julho de 2026), inviabilizando qualquer direito a retrabalho ou janelas de hotfix pós-prazo.
|
||||
|
||||
# **4\. Escopo da Demanda**
|
||||
|
||||
**Públicos e perfis**: calendário por faixa etária \+ profissionais de saúde \+ gestantes; perfil ativado por triagem autodeclarada no formulário.
|
||||
|
||||
**Motor de regras**: determinístico, em memória, 60+ regras vacinais em arquivos versionados, auditáveis linha a linha. Lógicas críticas: Febre Amarela (imunização) e dT/dTpa (contagem de doses).
|
||||
|
||||
**Sinalização da caderneta**: 5 status: Em Dia · Próxima dose · Atrasada · Não Digitalizada (sem registro digital para doses esperadas pré-2020) · Aguarda Semana (gestante que ainda não atingiu a IG mínima).
|
||||
|
||||
**Filtro de segurança clínica**: ocultação silenciosa e sistemática de vacinas vivas atenuadas (Tríplice Viral, Varicela, Dengue) quando o perfil ativo é gestante.
|
||||
|
||||
**Contingência de campanhas**: notas explicativas para Influenza e COVID-19, cujo histórico pode residir em índices de campanha (RCA) não consumidos pela API de rotina no MVP.
|
||||
|
||||
**Regularização**: canal direto para o WhatsApp institucional da SES-MG.
|
||||
|
||||
**Arquitetura:** hospedado em nuvem própria Isis integrado ao MG App.
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
# **5\. Escopo por Sprint**
|
||||
|
||||
## 5.1 · DESCOBERTA (D)
|
||||
|
||||
### Discovery Negocial e Institucional · I-02 · 120 USTs · 3 Semanas
|
||||
|
||||
* Definição de público-alvo e recorte do MVP
|
||||
* Validação institucional do escopo com Sub-secretaria de Vigilância SES-MG
|
||||
* Consolidação de decisões sobre perfis, funcionalidades e exclusões
|
||||
* Alinhamento jurídico/LGPD
|
||||
|
||||
### Discovery Técnico Integrado · I-02 · 120 USTs · 3 Semanas
|
||||
|
||||
* Análise da fonte PNI via bucket Elastic Search
|
||||
* Mapeamento empírico do catálogo de vacinas
|
||||
* Documentação do contrato de dados e schema do bucket
|
||||
* Definição da arquitetura de embedamento no MGapp em alinhamentos técnicos com Prodemge (SEPLAG)
|
||||
|
||||
### Discovery Clínico do Motor de Regras · I-05 · 80 USTs · 2 Semanas
|
||||
|
||||
* Levantamento das regras vacinais por calendário e perfil (universo teórico)
|
||||
* Recorte para MVP simplificado
|
||||
* Definição da hierarquia clínica de resolução de conflitos
|
||||
* Definição dos 6 status
|
||||
* Definição de regras de corte temporal 01/01/2020 para "não digitalizada"
|
||||
* Estruturação do vocabulário fechado de contraindicações
|
||||
|
||||
## 5.2 · DESIGN (DE)
|
||||
|
||||
### Design do MVP \- Vacina em Dia · I-02 · 80 USTs · 2 Semanas
|
||||
|
||||
* Desenho do fluxo do formulário em 2 camadas (multi-seleção \+ condicional)
|
||||
* Design das 12 telas do MVP
|
||||
* Microcopy revisada e aprovada pelo PO
|
||||
* Tratamento de casos de borda (gestante sem semana, condição especial redirecionada)
|
||||
* Especificação visual dos status (paleta, iconografia, tipografia)
|
||||
* Regras de exibição por perfil (BLOQUEADA oculta ao cidadão; AGUARDA IG apenas gestante)
|
||||
* Microcopy do canal de regularização (WhatsApp SES)
|
||||
* Aplicação de identidade institucional (brasão MG, SES-MG, Zé Gotinha)
|
||||
|
||||
### Design da Modelagem Clínica do Motor · I-05 · 80 USTs · 2 Semanas
|
||||
|
||||
* Análise de alternativas de motor e justificativa da escolha
|
||||
* Desenho de casos de uso clinicamente relevantes (personas)
|
||||
* Desenho do formulário de triagem — o que perguntar, inferir e não perguntar
|
||||
* Estratégias de fallback (PNI vazio, corrompido, parcial)
|
||||
* Decisões clínicas de design (hierarquia de conflitos, precedência de contraindicação)
|
||||
* Subfunções clínicas especiais (Febre Amarela pré-5a · dT esquema acumulado)
|
||||
* Trilha de validação clínica com SVE
|
||||
|
||||
## 5.3 · ARQUITETURA (A)
|
||||
|
||||
### Arquitetura da Aplicação · I-02 · 160 USTs · 4 Semanas
|
||||
|
||||
* Definição de stack e componentes
|
||||
* Especificação dos fluxos de comunicação (consulta PNI, autenticação, calendário)
|
||||
* Especificação da integração com MGapp
|
||||
* Definição das tabelas de domínio e de catálogo
|
||||
* Solução para não-persistência de dado vacinal
|
||||
* Especificação de APIs de autenticação (Swagger) e zonas de segurança
|
||||
|
||||
### Arquitetura Técnica do Motor · I-05 · 80 USTs · 2 Semanas
|
||||
|
||||
* Especificação do motor
|
||||
* Definição das etapas de processamento
|
||||
* Definição da hierarquia de conflitos implementável
|
||||
* Contrato do endpoint
|
||||
* Estratégia de carregamento das regras e de testabilidade
|
||||
* Registro de gatilhos de refatoração
|
||||
|
||||
## 5.4 · CONSTRUÇÃO
|
||||
|
||||
### Backend \+ Integração PNI · I-02 · 160 USTs · 4 Semanas
|
||||
|
||||
* Implementação das APIs REST conforme contratos
|
||||
* Implementação da chamada ao middleware Azure SES-MG (login → token → search)
|
||||
* Implementação dos 3 mappers de sigla PNI → vacina canônica
|
||||
* Tratamento de códigos PNI corrompidos
|
||||
* Consolidação do Swagger
|
||||
* Testes unitários e de integração com medição de cobertura
|
||||
|
||||
### Frontend \+ Publicação no MGApp · I-02 · 80 USTs · 2 Semanas
|
||||
|
||||
* Implementação das telas e do formulário
|
||||
* Aplicação da identidade visual (Zé Gotinha em 3 pontos)
|
||||
* Otimização mobile-first
|
||||
* Telas de fallback (aviso desktop · erro "acesse pelo MG App")
|
||||
* Consumo das APIs e tratamento de erros na UI
|
||||
* Publicação da URL (DNS ATI SES)
|
||||
* Recebimento e validação do token JWT
|
||||
* Cadastro da integração no back-office do MG App (Prodemge)
|
||||
|
||||
### Construção e Validação Final do Motor · I-05 · 160 USTs · 2 Semanas
|
||||
|
||||
* Carregamento das regras ativas
|
||||
* Implementação das etapas de processamento
|
||||
* Implementação da hierarquia de resolução de conflitos
|
||||
* Execução dos testes das personas e de casos adicionais
|
||||
* Disponibilização do endpoint
|
||||
* Sessão de validação clínica final com SVE
|
||||
* Incorporação de ajustes clínicos e testes de validação
|
||||
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
# **6\. Enquadramento no Termo de Referência**
|
||||
|
||||
| Item | Título | Descrição | USTs |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| I-02 | Implementação e Desenvolvimento da Solução: módulos, integrações e camada blockchain | Executar atividades de desenvolvimento de software para soluções com blockchain, incluindo contratos inteligentes e atividades relacionadas com a integração com sistemas legados. O escopo abrange, além da camada blockchain, o desenvolvimento de interfaces e APIs, a integração com sistemas de informação existentes da SES-MG, a conformidade com padrões de interoperabilidade, a segurança da informação com controle de acesso por identidade digital, a rastreabilidade e a conformidade com a LGPD e a PNSI. | Desenvolvimento de módulo da solução RMDS — interfaces, integração com sistema legado, autenticação por identidade digital e implantação segura. Desenvolvimento de interfaces: APIs REST documentadas em Swagger e front-end do Vacina em Dia. Conformidade LGPD Implantação: publicação em produção |
|
||||
| | | | |
|
||||
| I-05 | Serviços técnicos de inteligência e produtos analíticos de gestão em saúde | Serviços técnicos de inteligência aplicada aos dados da plataforma, compreendendo a concepção e o desenvolvimento de algoritmos analíticos, modelos de classificação, índices e dashboards gerenciais destinados a apoiar a tomada de decisão das áreas demandantes da SES-MG. Inclui extração de fenótipos, clusterização e priorização de populações e territórios, apuração de indicadores e disponibilização dos resultados em painéis, relatórios e produtos analíticos de gestão. | Modelagem do algoritmo: regras vacinais ativas com hierarquia de resolução de conflitos entre perfis.Incluindo regras de segurança clínica "De-para" semântico: mapeamento de 103 siglas do índice PNI/MG para vocabulário canônico de vacina. |
|
||||
| | | | |
|
||||
|
||||
# **7\. Estimativa de Esforço e Prazo**
|
||||
|
||||
Para a execução integral desta demanda, contemplando as fases de Descoberta, Design, Arquitetura e Construção, foi estimado o esforço de 1120 (mil cento e vinte) UST, a serem executadas no prazo de 5 (cinco) semanas.
|
||||
|
||||
| Quantidade de UST | Prazo de Execução | Início Previsto | Fim Previsto |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| **1.120 UST** | **4,42 semanas** | **02/06/2026** | **04/07/2026** |
|
||||
|
||||
| Fase | UST | Item TR | Timebox (Semanas) | Valor (R$) |
|
||||
| ----- | :---: | :---: | :---: | :---: |
|
||||
| **Descoberta** | 240 | I-02 | 6 | 127.999,20 |
|
||||
| | 80 | I-05 | 2 | 44.444,00 |
|
||||
| **Design** | 80 | I-02 | 2 | 42.666,40 |
|
||||
| | 80 | I-05 | 2 | 44.444,00 |
|
||||
| **Arquitetura** | 160 | I-02 | 4 | 85.332,80 |
|
||||
| | 80 | I-05 | 2 | 44.444,00 |
|
||||
| **Construção** | 240 | I-02 | 4 | 127.999,20 |
|
||||
| | 160 | I-05 | 2 | 88.888,00 |
|
||||
| **Total** | **1.120** | **I-02, I-05** | **24** | **R$ 606.217,60** |
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
#
|
||||
|
||||
# **8\. Aprovação**
|
||||
|
||||
Declaro estar de acordo com a Solicitação de Demanda apresentada pela contratada Isis Saúde, avaliados pela equipe técnica da Secretaria de Estado de Saúde de Minas Gerais e estão em conformidade com os requisitos estabelecidos no Termo de Referência do Pregão Eletrônico nº 29/2025, observados os termos, escopo, atividades e esforço descritos nesta Solicitação de Demanda.
|
||||
|
||||
| Product Owner | César Augusto Castro Alves Martins |
|
||||
| :---- | :---- |
|
||||
| Fiscal do Contrato | Guilherme Amaral Bernardino |
|
||||
| Gestor do Contrato | Rafael Matos Paiva |
|
||||
|
||||
##
|
||||
|
||||
## 8.1. Formalização do Aceite
|
||||
|
||||
Assinatura digital do aceite por meio do sistema Gov.br:
|
||||
|
||||
*Assinatura — Product Owner*
|
||||
|
||||
*Assinatura — Fiscal do Contrato*
|
||||
|
||||
*Assinatura — Gestor do Contrato*
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$comment": "Base de conhecimento do redator de SDs — RMDS / SES-MG (PE 29/2025). Terceiro arquivo da arquitetura: regras-sd.json = regras; schema-sd.json = formato; ESTE arquivo = histórico. Anexe-o ao projeto/Cowork junto com os outros dois. PREENCHIMENTO: apague os registros de exemplo (id terminando em '-EXEMPLO') e cadastre apenas fatos reais — o redator é proibido de inventar precedentes, e esta base só serve se for verdadeira. Uma linha nova em 'sds' a cada SD enviada; uma em 'capacidades' a cada capacidade entregue.",
|
||||
"versao": "0.1.0",
|
||||
"atualizado_em": "2026-08-07",
|
||||
|
||||
"convencoes": {
|
||||
"status_sd": ["rascunho", "enviada", "aprovada", "em_execucao", "concluida", "faturada", "cancelada"],
|
||||
"status_que_consomem_pool": ["enviada", "aprovada", "em_execucao", "concluida", "faturada"],
|
||||
"status_capacidade": ["em_construcao", "entregue"],
|
||||
"saldo_de_pool": "saldo(item) = pool_ust_total do item em regras-sd.json − soma de ust_por_item das SDs cujo status consome pool",
|
||||
"tags": "minúsculas, sem acento, singular quando possível; ex.: dashboard, integracao, niten, regulacao, vacinacao, treinamento, automacao",
|
||||
"ids": "SD-NN segue a numeração oficial do contrato; CAP-NN é sequencial interno desta base"
|
||||
},
|
||||
|
||||
"sds": [
|
||||
{
|
||||
"id": "SD-00-EXEMPLO",
|
||||
"$comment": "REGISTRO DE EXEMPLO — apague e substitua pelas SDs reais do contrato.",
|
||||
"titulo": "Painel de fila cirúrgica da macrorregião Centro",
|
||||
"projeto": "Fila Cirúrgica",
|
||||
"itens_tr": ["I-05"],
|
||||
"ust_total": 240,
|
||||
"ust_por_item": { "I-05": 240 },
|
||||
"status": "faturada",
|
||||
"periodo": { "inicio": "2026-01-05", "fim": "2026-01-23" },
|
||||
"resumo": "Uma frase dizendo o que a SD entregou e para quem.",
|
||||
"tags": ["dashboard", "regulacao", "fila-cirurgica"]
|
||||
}
|
||||
],
|
||||
|
||||
"capacidades": [
|
||||
{
|
||||
"id": "CAP-00-EXEMPLO",
|
||||
"$comment": "REGISTRO DE EXEMPLO — apague e substitua pelas capacidades reais já entregues na plataforma.",
|
||||
"titulo": "Integração com o barramento de dados do sistema XYZ",
|
||||
"descricao": "O que a plataforma já sabe fazer, escrito para responder à pergunta 'isso precisa ser construído de novo?'",
|
||||
"origem_sd": "SD-00-EXEMPLO",
|
||||
"item_tr": "I-02",
|
||||
"status": "entregue",
|
||||
"tags": ["integracao", "xyz"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copie este arquivo para ".env" (na mesma pasta) e preencha a chave.
|
||||
# O arquivo .env NUNCA deve ser versionado nem compartilhado.
|
||||
|
||||
# Chave da API da Anthropic (crie em https://console.anthropic.com -> API Keys)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Provedor de LLM (por enquanto apenas "anthropic"; adaptador OpenAI previsto para a fase 1.1)
|
||||
LLM_PROVIDER=anthropic
|
||||
|
||||
# Modelo padrão
|
||||
LLM_MODEL=claude-opus-5
|
||||
|
||||
# Modelos oferecidos nos seletores da interface (Nova SD e Base de Conhecimento),
|
||||
# separados por vírgula. O LLM_MODEL acima sempre entra na lista, como padrão.
|
||||
# Obs.: o custo estimado no rodapé usa a tabela de preços do Opus.
|
||||
LLM_MODELOS=claude-sonnet-5,claude-haiku-4-5-20251001
|
||||
|
||||
# Esforço de raciocínio do modelo: low | medium | high | xhigh | max
|
||||
# (vazio = padrão da API, que é high). "medium" acelera bem a entrevista
|
||||
# com perda mínima de qualidade; suba para high se notar respostas rasas.
|
||||
LLM_EFFORT=medium
|
||||
|
||||
# Porta do servidor local
|
||||
PORT=3344
|
||||
|
||||
# Login do app (usuário único). Enquanto os dois campos estiverem vazios,
|
||||
# o app roda SEM login e mostra um aviso.
|
||||
APP_USUARIO=
|
||||
APP_SENHA=
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
@@ -0,0 +1,88 @@
|
||||
# Redator de SD — RMDS / SES-MG × Isis Saúde
|
||||
|
||||
App local que conduz a entrevista de redação de Solicitações de Demanda (SD) com um LLM,
|
||||
valida em código contra as regras do contrato (PE 29/2025) e mantém a base de conhecimento.
|
||||
|
||||
## Primeira execução
|
||||
|
||||
1. **Chave de API** — crie em https://console.anthropic.com → *API Keys* e coloque um crédito
|
||||
inicial pequeno (US$ 5–10 rendem dezenas de SDs).
|
||||
2. **Configuração** — copie `.env.example` para `.env` (nesta pasta) e preencha
|
||||
`ANTHROPIC_API_KEY`. Defina também `APP_USUARIO` e `APP_SENHA` para ativar a
|
||||
tela de login (enquanto vazios, o app roda aberto e mostra um aviso).
|
||||
O `.env` nunca deve ser versionado nem compartilhado.
|
||||
3. **Dependências** (só na primeira vez):
|
||||
```
|
||||
cd D:\IasisSaude\app
|
||||
npm install
|
||||
```
|
||||
4. **Rodar**:
|
||||
```
|
||||
npm start
|
||||
```
|
||||
e abra http://localhost:3344 no navegador.
|
||||
|
||||
## Como funciona
|
||||
|
||||
- **Nova SD**: cole a demanda bruta → o redator entrevista você (uma pergunta por turno,
|
||||
opções viram botões) enquanto o painel do documento se atualiza ao vivo.
|
||||
- **Validação**: os itens `codigo` do checklist são recalculados em JavaScript a cada
|
||||
atualização — e o resultado é realimentado ao modelo, que se corrige sozinho.
|
||||
- **Trava de integridade**: exportação e envio ficam bloqueados até todos os artefatos
|
||||
serem confirmados (checkbox + data) e nenhuma falha de código restar.
|
||||
- **Envio**: atribui o número definitivo (SD-NN) e registra a SD em
|
||||
`..\base-conhecimento.json` — saldo de pools e precedentes ficam atualizados
|
||||
para as próximas entrevistas.
|
||||
- **Documento / impressão**: o botão "Documento / imprimir (PDF)" abre a SD formatada
|
||||
como documento oficial em uma aba pronta para Ctrl+P → salvar como PDF. Com pendências
|
||||
bloqueantes abertas, o documento sai com marca de RASCUNHO.
|
||||
- **Validar JSON externo** (menu no topo): cole o JSON de uma SD que não nasceu no app —
|
||||
o validador recalcula tudo, roda o checklist e libera documento/download, sem salvar nada.
|
||||
- **Alimentar base** (menu no topo): envie o documento de uma SD real (`.pdf`, `.md` ou `.txt`;
|
||||
`.docx` deve ser exportado como PDF). Uma chamada de LLM extrai o registro estruturado
|
||||
(itens, UST por item, período, status) e sugere capacidades reutilizáveis; a conferência
|
||||
determinística em código checa somas, itens, status e impacto nos pools. **Nada é gravado
|
||||
sem revisão**: você edita o registro, marca/desmarca capacidades e só então grava em
|
||||
`..\base-conhecimento.json`. Erros de conferência bloqueiam a gravação; regravar o mesmo
|
||||
SD-NN substitui o registro anterior (capacidades, porém, são sempre acrescentadas — ao
|
||||
regravar uma SD, desmarque as que já entraram na primeira vez).
|
||||
- **Login**: usuário único definido no `.env`. As sessões vivem na memória do servidor —
|
||||
reiniciar o servidor exige novo login.
|
||||
|
||||
## Arquivos de configuração (raiz do projeto, `D:\IasisSaude`)
|
||||
|
||||
| Arquivo | Papel |
|
||||
|---|---|
|
||||
| `prompt-redator-sd.md` | Comportamento do redator (system prompt) |
|
||||
| `regras-sd.json` | Regras duras do contrato — fonte única |
|
||||
| `schema-sd.json` | Formato da SD |
|
||||
| `base-conhecimento.json` | Histórico: SDs enviadas e capacidades da plataforma |
|
||||
|
||||
Editar qualquer um deles muda o app na próxima reinicialização — não é preciso mexer em código.
|
||||
|
||||
## Custos
|
||||
|
||||
O rodapé do chat mostra tokens e custo estimado em tempo real. O system prompt
|
||||
(prompt + regras + base) é cacheado pela API — turnos seguintes da mesma conversa
|
||||
pagam ~10% do preço no prefixo repetido.
|
||||
|
||||
## Dados
|
||||
|
||||
- `data/sds.json` — SDs e conversas (criado automaticamente; fora do versionamento).
|
||||
- Provedor/modelo/esforço configuráveis no `.env` (`LLM_PROVIDER`, `LLM_MODEL`,
|
||||
`LLM_EFFORT` — low/medium/high/xhigh/max; medium recomendado para a entrevista).
|
||||
Adaptador OpenAI previsto para a fase 1.1 (`src/llm/provider.js`).
|
||||
- **Seletor de modelo**: `LLM_MODELOS` (lista separada por vírgula) define os modelos
|
||||
oferecidos nos seletores das telas Nova SD e Base de Conhecimento; o `LLM_MODEL`
|
||||
sempre entra como padrão. O modelo escolhido fica fixo para a SD inteira — trocar
|
||||
no meio da conversa descartaria o cache de prompt. O custo do rodapé usa a tabela
|
||||
de preços do Opus (a observação ao lado do valor lembra isso).
|
||||
|
||||
## Atualização dos arquivos de configuração
|
||||
|
||||
O prompt do redator é remontado automaticamente quando `base-conhecimento.json`,
|
||||
`prompt-redator-sd.md`, `regras-sd.json` ou `schema-sd.json` mudam no disco —
|
||||
vale a partir da PRÓXIMA conversa, sem reiniciar o servidor. Exceção: mudanças em
|
||||
`regras-sd.json`/`schema-sd.json` também alimentam o validador em código e a
|
||||
ferramenta do modelo, que carregam na subida — para essas duas, reinicie.
|
||||
Mudanças no `.env` sempre exigem reinício.
|
||||
@@ -0,0 +1,3 @@
|
||||
Subir Aplicação
|
||||
|
||||
cd D:\IasisSaude\app && npm start
|
||||
Generated
+890
@@ -0,0 +1,890 @@
|
||||
{
|
||||
"name": "redator-sd",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "redator-sd",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.74.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.74.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.74.0.tgz",
|
||||
"integrity": "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "redator-sd",
|
||||
"version": "0.1.0",
|
||||
"description": "Redator de Solicitações de Demanda (SD) — RMDS / SES-MG × Isis Saúde",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.74.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) =>
|
||||
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const fmtD = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? `${m[3]}/${m[2]}/${m[1]}` : "—";
|
||||
};
|
||||
const dayNum = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000 : NaN;
|
||||
};
|
||||
|
||||
// Preços do claude-opus-5 (USD por 1M tokens) para o custo estimado no rodapé.
|
||||
const PRECO = { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 };
|
||||
|
||||
let statusApp = null;
|
||||
let sdAtual = null; // { id, numero, status, chat, rascunho, validacao, bloqueios, uso }
|
||||
let ocupado = false;
|
||||
let modoExterno = false; // tela "Validar JSON externo"
|
||||
let externo = null; // { sd, exportado, validacao, bloqueios }
|
||||
let ultimosBloqueios = [];
|
||||
|
||||
// ---------- Markdown mínimo (negrito, código, títulos, listas, OPCAO:) ----------
|
||||
function renderMd(texto) {
|
||||
const linhas = String(texto || "").split("\n");
|
||||
const out = [];
|
||||
let lista = null; // "ul" | "ol"
|
||||
const fechaLista = () => { if (lista) { out.push(`</${lista}>`); lista = null; } };
|
||||
const inline = (s) =>
|
||||
esc(s)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
for (const raw of linhas) {
|
||||
const l = raw.trimEnd();
|
||||
if (/^OPCAO:/i.test(l.trim())) continue; // vira botão, não texto
|
||||
const h = l.match(/^(#{1,4})\s+(.*)/);
|
||||
const ul = l.match(/^\s*[-•]\s+(.*)/);
|
||||
const ol = l.match(/^\s*\d+[.)]\s+(.*)/);
|
||||
if (h) { fechaLista(); out.push(`<h3>${inline(h[2])}</h3>`); }
|
||||
else if (ul) { if (lista !== "ul") { fechaLista(); out.push("<ul>"); lista = "ul"; } out.push(`<li>${inline(ul[1])}</li>`); }
|
||||
else if (ol) { if (lista !== "ol") { fechaLista(); out.push("<ol>"); lista = "ol"; } out.push(`<li>${inline(ol[1])}</li>`); }
|
||||
else if (l.trim() === "") { fechaLista(); }
|
||||
else { fechaLista(); out.push(`<p>${inline(l)}</p>`); }
|
||||
}
|
||||
fechaLista();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function extrairOpcoes(texto) {
|
||||
return String(texto || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^OPCAO:/i.test(l))
|
||||
.map((l) => l.replace(/^OPCAO:\s*/i, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- Tema (automático → claro → escuro; escolha guardada no navegador) ----------
|
||||
const TEMAS = ["auto", "claro", "escuro"];
|
||||
const TEMA_ICONE = { auto: "🌗", claro: "☀️", escuro: "🌙" };
|
||||
const TEMA_ROTULO = { auto: "automático (segue o sistema)", claro: "claro", escuro: "escuro" };
|
||||
let temaAtual = localStorage.getItem("sd_tema");
|
||||
if (!TEMAS.includes(temaAtual)) temaAtual = "auto";
|
||||
|
||||
function aplicarTema(t) {
|
||||
const raiz = document.documentElement;
|
||||
if (t === "claro") raiz.dataset.theme = "light";
|
||||
else if (t === "escuro") raiz.dataset.theme = "dark";
|
||||
else delete raiz.dataset.theme; // automático: quem manda é o sistema
|
||||
$("btnTema").textContent = TEMA_ICONE[t];
|
||||
$("btnTema").title = `Tema: ${TEMA_ROTULO[t]} — clique para alternar`;
|
||||
}
|
||||
aplicarTema(temaAtual);
|
||||
|
||||
$("btnTema").addEventListener("click", () => {
|
||||
temaAtual = TEMAS[(TEMAS.indexOf(temaAtual) + 1) % TEMAS.length];
|
||||
localStorage.setItem("sd_tema", temaAtual);
|
||||
aplicarTema(temaAtual);
|
||||
});
|
||||
|
||||
// ---------- Autenticação + navegação ----------
|
||||
window.addEventListener("hashchange", rotear);
|
||||
window.addEventListener("DOMContentLoaded", iniciar);
|
||||
|
||||
async function iniciar() {
|
||||
let sess = null;
|
||||
try { sess = await (await fetch("/api/sessao")).json(); } catch {}
|
||||
if (!sess) { $("statusChip").textContent = "servidor indisponível"; return; }
|
||||
$("btnSair").hidden = !sess.precisa_login;
|
||||
if (sess.precisa_login && !sess.logado) { mostrarLogin(); return; }
|
||||
await entrar();
|
||||
}
|
||||
|
||||
async function entrar() {
|
||||
esconderTodas();
|
||||
await carregarStatus();
|
||||
rotear();
|
||||
}
|
||||
|
||||
function esconderTodas() {
|
||||
for (const id of ["telaLogin", "telaHome", "telaConversa", "telaValidar", "telaBase"]) $(id).hidden = true;
|
||||
}
|
||||
|
||||
function mostrarLogin() {
|
||||
esconderTodas();
|
||||
$("telaLogin").hidden = false;
|
||||
$("loginErro").hidden = true;
|
||||
$("loginUsuario").focus();
|
||||
}
|
||||
|
||||
$("formLogin").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
$("btnLogin").disabled = true;
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ usuario: $("loginUsuario").value, senha: $("loginSenha").value }),
|
||||
}).catch(() => null);
|
||||
$("btnLogin").disabled = false;
|
||||
if (r?.ok) { $("loginSenha").value = ""; await entrar(); }
|
||||
else {
|
||||
$("loginErro").textContent = "Usuário ou senha inválidos.";
|
||||
$("loginErro").hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("btnSair").addEventListener("click", async () => {
|
||||
await fetch("/api/logout", { method: "POST" }).catch(() => {});
|
||||
location.hash = "#/";
|
||||
mostrarLogin();
|
||||
});
|
||||
|
||||
function exigirLogin(resp) {
|
||||
// Sessão expirada/servidor reiniciado: volta para a tela de login.
|
||||
if (resp && resp.status === 401) { mostrarLogin(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
async function carregarStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/status");
|
||||
if (exigirLogin(r)) return;
|
||||
statusApp = await r.json();
|
||||
$("statusChip").textContent = statusApp.configurado
|
||||
? `${statusApp.provider} · ${statusApp.model} · próxima: SD-${statusApp.proximo_numero}`
|
||||
: "sem chave de API";
|
||||
$("avisoConfig").hidden = statusApp.configurado;
|
||||
popularSeletoresModelo();
|
||||
} catch {
|
||||
$("statusChip").textContent = "servidor indisponível";
|
||||
}
|
||||
}
|
||||
|
||||
// Seletores de modelo (Nova SD e Base de Conhecimento) — lista do .env (LLM_MODEL + LLM_MODELOS).
|
||||
function popularSeletoresModelo() {
|
||||
const modelos = statusApp?.modelos || [];
|
||||
for (const id of ["selModelo", "selModeloBase"]) {
|
||||
const sel = $(id);
|
||||
const anterior = sel.value;
|
||||
sel.innerHTML = modelos
|
||||
.map((m) => `<option value="${esc(m)}">${esc(m)}${m === statusApp.model ? " (padrão)" : ""}</option>`)
|
||||
.join("");
|
||||
// preserva a escolha do usuário entre recargas de status; senão, usa o padrão
|
||||
sel.value = modelos.includes(anterior) ? anterior : statusApp.model;
|
||||
}
|
||||
}
|
||||
|
||||
function moverPainel(slotId) {
|
||||
const painel = $("painel");
|
||||
const slot = $(slotId);
|
||||
if (painel.parentElement !== slot) slot.appendChild(painel);
|
||||
}
|
||||
|
||||
function rotear() {
|
||||
if ($("telaLogin") && !$("telaLogin").hidden) return; // aguardando login
|
||||
const m = location.hash.match(/^#\/sd\/(.+)$/);
|
||||
if (location.hash === "#/validar") mostrarValidar();
|
||||
else if (location.hash === "#/base") mostrarBase();
|
||||
else if (m) abrirConversa(m[1]);
|
||||
else mostrarHome();
|
||||
}
|
||||
|
||||
// ---------- Home ----------
|
||||
async function mostrarHome() {
|
||||
esconderTodas();
|
||||
$("telaHome").hidden = false;
|
||||
sdAtual = null;
|
||||
modoExterno = false;
|
||||
const rList = await fetch("/api/sds");
|
||||
if (exigirLogin(rList)) return;
|
||||
const lista = await rList.json();
|
||||
$("listaSds").innerHTML = lista.length
|
||||
? lista
|
||||
.sort((a, b) => (a.criadaEm < b.criadaEm ? 1 : -1))
|
||||
.map(
|
||||
(s) => `<a class="sd-item" href="#/sd/${s.id}">
|
||||
<span class="num">SD-${s.numero}</span>
|
||||
<span class="tit">${esc(s.titulo)}</span>
|
||||
<span class="st st-${s.status}">${s.status}</span>
|
||||
<button type="button" class="btn-excluir" data-id="${s.id}" data-status="${s.status}"
|
||||
data-titulo="${esc(s.titulo)}" title="Excluir da lista">✕</button></a>`
|
||||
)
|
||||
.join("")
|
||||
: '<p class="hint">Nenhuma SD ainda — cole a primeira demanda acima.</p>';
|
||||
$("listaSds").querySelectorAll(".btn-excluir").forEach((b) =>
|
||||
b.addEventListener("click", async (ev) => {
|
||||
ev.preventDefault(); // o botão vive dentro do link da SD
|
||||
ev.stopPropagation();
|
||||
const { id, status, titulo } = ev.currentTarget.dataset;
|
||||
const aviso = status === "enviada"
|
||||
? `Excluir a conversa de "${titulo}" da lista?\n\nEla já foi ENVIADA: o registro na base de conhecimento (saldos e precedentes) NÃO será apagado — só a conversa aqui do app.`
|
||||
: `Excluir o rascunho "${titulo}"?\n\nA conversa e o rascunho serão apagados de vez.`;
|
||||
if (!confirm(aviso)) return;
|
||||
const r = await fetch(`/api/sds/${id}`, { method: "DELETE" });
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { alert("Não foi possível excluir."); return; }
|
||||
await carregarStatus(); // numeração provisória pode mudar
|
||||
await mostrarHome();
|
||||
})
|
||||
);
|
||||
if (statusApp?.saldos) {
|
||||
$("saldosBox").innerHTML = Object.entries(statusApp.saldos)
|
||||
.map(
|
||||
([item, v]) => `<div class="saldo-chip">
|
||||
<div class="label">${esc(item)}</div>
|
||||
<div class="value">${v.saldo.toLocaleString("pt-BR")} UST</div>
|
||||
<div class="sub">de ${v.pool_total.toLocaleString("pt-BR")} · consumido ${v.consumido.toLocaleString("pt-BR")}</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
$("btnIniciar").addEventListener("click", async () => {
|
||||
const demanda = $("demandaInput").value.trim();
|
||||
if (!demanda || ocupado) return;
|
||||
$("btnIniciar").disabled = true;
|
||||
// Prepara a tela de conversa e transmite o primeiro turno
|
||||
esconderTodas();
|
||||
$("telaConversa").hidden = false;
|
||||
modoExterno = false;
|
||||
moverPainel("slotPainelConversa");
|
||||
$("mensagens").innerHTML = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
resetPainel();
|
||||
addMsgUser(demanda);
|
||||
const resp = await fetch("/api/sds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ demanda, modelo: $("selModelo").value }),
|
||||
});
|
||||
if (exigirLogin(resp)) return;
|
||||
const sdId = resp.headers.get("X-Sd-Id");
|
||||
history.replaceState(null, "", `#/sd/${sdId}`);
|
||||
sdAtual = { id: sdId, chat: [], rascunho: null };
|
||||
$("demandaInput").value = "";
|
||||
$("btnIniciar").disabled = false;
|
||||
await consumirStream(resp);
|
||||
await recarregarSd(); // pega numero/status/uso consolidados
|
||||
});
|
||||
|
||||
// ---------- Conversa ----------
|
||||
async function abrirConversa(id) {
|
||||
if (sdAtual?.id === id && ocupado) return; // já está na tela, streaming em curso
|
||||
esconderTodas();
|
||||
$("telaConversa").hidden = false;
|
||||
modoExterno = false;
|
||||
moverPainel("slotPainelConversa");
|
||||
sdAtual = { id };
|
||||
await recarregarSd(true);
|
||||
}
|
||||
|
||||
async function recarregarSd(renderChat = false) {
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}`);
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { location.hash = "#/"; return; }
|
||||
const d = await r.json();
|
||||
sdAtual = d;
|
||||
if (renderChat) {
|
||||
$("mensagens").innerHTML = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
for (const m of d.chat || []) {
|
||||
if (m.role === "user") addMsgUser(m.texto);
|
||||
else addMsgAssistant(m.texto, true);
|
||||
}
|
||||
const ultimo = (d.chat || []).filter((m) => m.role === "assistant").pop();
|
||||
if (ultimo) renderOpcoes(extrairOpcoes(ultimo.texto));
|
||||
}
|
||||
renderPainel(d.rascunho, d.validacao, d.bloqueios);
|
||||
renderUso(d.uso);
|
||||
const soLeitura = d.status === "enviada";
|
||||
$("msgInput").disabled = soLeitura;
|
||||
$("btnEnviarMsg").disabled = soLeitura;
|
||||
if (soLeitura) $("msgInput").placeholder = "SD enviada — somente leitura.";
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
function addMsgUser(texto) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg user";
|
||||
div.textContent = texto;
|
||||
$("mensagens").appendChild(div);
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
function addMsgAssistant(texto, pronto = false) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg assistant";
|
||||
div.innerHTML = pronto ? renderMd(texto) : '<span class="digitando">▍</span>';
|
||||
$("mensagens").appendChild(div);
|
||||
rolarChat();
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderOpcoes(opcoes) {
|
||||
$("opcoes").innerHTML = "";
|
||||
for (const o of opcoes) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = o;
|
||||
b.addEventListener("click", () => enviarMensagem(o));
|
||||
$("opcoes").appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function rolarChat() {
|
||||
const el = $("mensagens");
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
$("formMsg").addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const t = $("msgInput").value.trim();
|
||||
if (t) enviarMensagem(t);
|
||||
});
|
||||
$("msgInput").addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
$("formMsg").requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
async function enviarMensagem(texto) {
|
||||
if (ocupado || !sdAtual?.id) return;
|
||||
$("msgInput").value = "";
|
||||
$("opcoes").innerHTML = "";
|
||||
addMsgUser(texto);
|
||||
const resp = await fetch(`/api/sds/${sdAtual.id}/mensagem`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ texto }),
|
||||
});
|
||||
if (exigirLogin(resp)) return;
|
||||
if (!resp.ok && !resp.body) {
|
||||
addMsgAssistant("⚠ Erro ao enviar a mensagem.", true);
|
||||
return;
|
||||
}
|
||||
await consumirStream(resp);
|
||||
}
|
||||
|
||||
async function consumirStream(resp) {
|
||||
ocupado = true;
|
||||
$("btnEnviarMsg").disabled = true;
|
||||
const bolha = addMsgAssistant("");
|
||||
let texto = "";
|
||||
// "pensando…" cobre o silêncio do raciocínio interno do modelo: antes da
|
||||
// primeira palavra e entre as chamadas de ferramenta (validação do rascunho).
|
||||
let pensando = true;
|
||||
const pinta = () => {
|
||||
bolha.innerHTML =
|
||||
renderMd(texto) +
|
||||
(pensando ? '<span class="pensando">pensando…</span>' : '<span class="digitando">▍</span>');
|
||||
rolarChat();
|
||||
};
|
||||
pinta();
|
||||
try {
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const linha = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!linha) continue;
|
||||
let ev;
|
||||
try { ev = JSON.parse(linha); } catch { continue; }
|
||||
if (ev.t === "delta") {
|
||||
texto += ev.text;
|
||||
pensando = false;
|
||||
pinta();
|
||||
} else if (ev.t === "rascunho") {
|
||||
renderPainel(ev.sd, ev.validacao, ev.bloqueios);
|
||||
pensando = true; // o modelo volta a raciocinar sobre o resultado da validação
|
||||
pinta();
|
||||
} else if (ev.t === "uso") {
|
||||
renderUso(ev.uso);
|
||||
} else if (ev.t === "erro") {
|
||||
texto += `\n\n⚠ ${ev.msg}`;
|
||||
pensando = false;
|
||||
pinta();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
texto += "\n\n⚠ Conexão interrompida — recarregue a página para retomar.";
|
||||
}
|
||||
bolha.innerHTML = renderMd(texto);
|
||||
renderOpcoes(extrairOpcoes(texto));
|
||||
ocupado = false;
|
||||
$("btnEnviarMsg").disabled = false;
|
||||
rolarChat();
|
||||
}
|
||||
|
||||
// ---------- Painel do documento ----------
|
||||
function resetPainel() {
|
||||
$("painelVazio").hidden = false;
|
||||
$("painelConteudo").hidden = true;
|
||||
}
|
||||
|
||||
function renderPainel(sd, validacao, bloqueios) {
|
||||
if (!sd) { resetPainel(); return; }
|
||||
ultimosBloqueios = bloqueios || [];
|
||||
if (!modoExterno && sdAtual) { sdAtual.rascunho = sd; }
|
||||
$("painelVazio").hidden = true;
|
||||
$("painelConteudo").hidden = false;
|
||||
const E = sd.entregaveis || [];
|
||||
const cab = sd.cabecalho || {};
|
||||
const R = validacao?.resultados || {};
|
||||
const totais = validacao?.totais || {};
|
||||
|
||||
// Resumo
|
||||
$("pResumo").innerHTML = `
|
||||
<div class="ptitulo">${esc(cab.titulo || cab.projeto || "SD em elaboração")}</div>
|
||||
<div class="resumo-chips">
|
||||
<div class="rchip"><div class="label">Nº</div><div class="value">${esc(cab.numero_sd || (sdAtual?.numero ? "SD-" + sdAtual.numero : "—"))}</div></div>
|
||||
<div class="rchip"><div class="label">Total</div><div class="value">${validacao?.soma_ust ?? "—"} UST</div></div>
|
||||
<div class="rchip"><div class="label">Prazo</div><div class="value">${esc(cab.prazo_execucao_semanas ?? "—")} sem.</div></div>
|
||||
<div class="rchip"><div class="label">Itens</div><div class="value" style="font-size:12px">${(cab.itens_tr || []).join(" + ") || "—"}</div></div>
|
||||
<div class="rchip"><div class="label">✓/!/✗/○</div><div class="value" style="font-size:12px">${totais.ok ?? 0}/${totais.alerta ?? 0}/${totais.falha ?? 0}/${totais.pendente ?? 0}</div></div>
|
||||
</div>`;
|
||||
|
||||
// Quadro
|
||||
let linhas = E.map((e) => {
|
||||
const par = (e.paralelo_com || []).length ? " ∥" : "";
|
||||
return `<tr><td class="num">${e.n ?? ""}</td><td>${esc(e.nome)}${par}</td><td>${esc(e.tipo || "")}</td>
|
||||
<td>${esc(e.item_tr || "")}</td><td class="num">${e.semanas ?? ""}</td><td class="num">${e.ust ?? ""}</td>
|
||||
<td>${fmtD(e.periodo?.inicio)}–${fmtD(e.periodo?.fim)}</td></tr>`;
|
||||
}).join("");
|
||||
const porItem = validacao?.soma_por_item || {};
|
||||
if (Object.keys(porItem).length > 1) {
|
||||
linhas += Object.keys(porItem).sort()
|
||||
.map((i) => `<tr class="subtotal"><td colspan="5">Subtotal ${esc(i)}</td><td class="num">${porItem[i]}</td><td></td></tr>`)
|
||||
.join("");
|
||||
}
|
||||
linhas += `<tr class="total"><td colspan="5">TOTAL</td><td class="num">${validacao?.soma_ust ?? ""}</td><td></td></tr>`;
|
||||
$("pQuadro").innerHTML = `<table class="quadro">
|
||||
<tr><th>#</th><th>Entregável</th><th>Tipo</th><th>Item</th><th>Sem</th><th>UST</th><th>Período</th></tr>${linhas}</table>`;
|
||||
|
||||
// Artefatos (trava de integridade)
|
||||
$("pArtefatos").innerHTML = E.map((e, ei) => {
|
||||
const arts = (e.artefatos || []).map((a, ai) => {
|
||||
const bad = a.data && e.periodo?.fim && dayNum(a.data) > dayNum(e.periodo.fim);
|
||||
return `<div class="art-row ${a.confirmado_pelo_usuario ? "confirmed" : "unconfirmed"}">
|
||||
<input type="checkbox" data-e="${ei}" data-a="${ai}" ${a.confirmado_pelo_usuario ? "checked" : ""} aria-label="Confirmar artefato">
|
||||
<div class="art-main">
|
||||
<label>${esc(a.nome)}</label>
|
||||
<input type="date" data-e="${ei}" data-a="${ai}" value="${esc(a.data || "")}" aria-label="Data do artefato">
|
||||
${bad ? `<div class="flag bad">data posterior ao fim do entregável (${fmtD(e.periodo.fim)})</div>`
|
||||
: a.confirmado_pelo_usuario && !a.data ? `<div class="flag miss">confirmado sem data</div>` : ""}
|
||||
</div></div>`;
|
||||
}).join("");
|
||||
return arts ? `<div class="art-entr"><h4>E${e.n} — ${esc(e.nome)}</h4>${arts}</div>` : "";
|
||||
}).join("") || '<p class="hint">Ainda sem artefatos registrados.</p>';
|
||||
|
||||
$("pArtefatos").querySelectorAll("input[type=checkbox]").forEach((cb) =>
|
||||
cb.addEventListener("change", (ev) =>
|
||||
atualizarArtefato(+ev.target.dataset.e, +ev.target.dataset.a, { confirmado: ev.target.checked })
|
||||
)
|
||||
);
|
||||
$("pArtefatos").querySelectorAll("input[type=date]").forEach((di) =>
|
||||
di.addEventListener("change", (ev) =>
|
||||
atualizarArtefato(+ev.target.dataset.e, +ev.target.dataset.a, { data: ev.target.value || "" })
|
||||
)
|
||||
);
|
||||
|
||||
// Checklist
|
||||
const ICONS = { ok: "✓", alerta: "!", falha: "✗", pendente: "○" };
|
||||
const checklist = statusApp?.checklist || [];
|
||||
$("pChecklist").innerHTML = checklist.map((c) => {
|
||||
const r = R[c.id] || { status: "pendente", obs: "" };
|
||||
return `<div class="check-item st-${r.status}">
|
||||
<span class="icon">${ICONS[r.status]}</span>
|
||||
<span class="id">${c.id}</span>
|
||||
<span>${esc(c.regra)}${r.obs ? `<span class="obs">${esc(r.obs)}</span>` : ""}</span></div>`;
|
||||
}).join("");
|
||||
|
||||
// Pendências
|
||||
const pend = [...(sd.pendencias || [])];
|
||||
(bloqueios || []).forEach((b) => pend.push("Bloqueante: " + b));
|
||||
$("pPendencias").innerHTML = pend.length
|
||||
? pend.map((p) => `<li>${esc(p)}</li>`).join("")
|
||||
: "<li>Nenhuma pendência registrada.</li>";
|
||||
|
||||
// Fechamento
|
||||
const bloqueado = (bloqueios || []).length > 0;
|
||||
const lock = $("pBloqueios");
|
||||
lock.className = "lock-msg " + (bloqueado ? "locked" : "open");
|
||||
lock.textContent = bloqueado
|
||||
? "Exportação bloqueada — " + bloqueios.join("; ") + "."
|
||||
: "SD liberada: artefatos confirmados e nenhuma falha de código.";
|
||||
$("btnBaixar").disabled = bloqueado;
|
||||
$("btnEnviarSd").hidden = modoExterno;
|
||||
$("btnEnviarSd").disabled = bloqueado || sdAtual?.status === "enviada";
|
||||
}
|
||||
|
||||
async function atualizarArtefato(e, a, corpo) {
|
||||
if (modoExterno) {
|
||||
const art = externo?.sd?.entregaveis?.[e]?.artefatos?.[a];
|
||||
if (!art) return;
|
||||
if (typeof corpo.confirmado === "boolean") art.confirmado_pelo_usuario = corpo.confirmado;
|
||||
if (corpo.data !== undefined) {
|
||||
if (corpo.data) art.data = corpo.data;
|
||||
else delete art.data;
|
||||
}
|
||||
await validarExterno(false);
|
||||
return;
|
||||
}
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/artefato`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ e, a, ...corpo }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
renderPainel(d.sd, d.validacao, d.bloqueios);
|
||||
}
|
||||
}
|
||||
|
||||
$("btnBaixar").addEventListener("click", () => {
|
||||
if (modoExterno) {
|
||||
if (externo?.exportado) baixarJsonLocal(externo.exportado);
|
||||
return;
|
||||
}
|
||||
window.location.href = `/api/sds/${sdAtual.id}/export`;
|
||||
});
|
||||
|
||||
function baixarJsonLocal(obj) {
|
||||
const nome = (obj.cabecalho?.numero_sd || "sd").toLowerCase().replace(/[^a-z0-9-]+/g, "-") + ".json";
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = nome;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
$("btnEnviarSd").addEventListener("click", async () => {
|
||||
if (!confirm("Marcar como enviada? O número definitivo será atribuído e a SD entra na base de conhecimento.")) return;
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/enviar`, { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
alert(`SD registrada na base como ${d.registro_na_base.id}.`);
|
||||
await carregarStatus();
|
||||
await recarregarSd();
|
||||
} else {
|
||||
alert(d.erro + (d.bloqueios ? "\n- " + d.bloqueios.join("\n- ") : ""));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Documento formatado + impressão/PDF ----------
|
||||
const CSS_IMPRESSAO = `
|
||||
body{margin:0; padding:32px 40px; background:#fff; color:#000;
|
||||
font-family:Georgia,"Times New Roman",serif; font-size:12.5pt; line-height:1.6;}
|
||||
h1{font-size:16pt; margin:0 0 2px; line-height:1.3;}
|
||||
.doc-sub{font-family:"Segoe UI",Arial,sans-serif; font-size:8.5pt; color:#555;
|
||||
text-transform:uppercase; letter-spacing:.08em; margin-bottom:18px;}
|
||||
.marca-rascunho{font-family:"Segoe UI",Arial,sans-serif; font-size:9pt; color:#B3362B;
|
||||
border:1px solid #B3362B; display:inline-block; padding:2px 10px; margin-bottom:14px;}
|
||||
h2{font-size:12.5pt; margin:22px 0 8px; border-bottom:2px solid #0B6B70; padding-bottom:3px;}
|
||||
h3{font-size:11pt; margin:16px 0 5px;}
|
||||
p{margin:0 0 10px;}
|
||||
table{border-collapse:collapse; width:100%; font-size:9.5pt;}
|
||||
th,td{border:1px solid #999; padding:4px 7px; text-align:left; vertical-align:top;}
|
||||
th{font-family:"Segoe UI",Arial,sans-serif; font-size:8pt; text-transform:uppercase;
|
||||
letter-spacing:.04em; background:#E1EFEF;}
|
||||
tr.subtotal td{font-weight:700; background:#E1EFEF;}
|
||||
tr.total td{font-weight:700; border-top:2px solid #0B6B70;}
|
||||
td.num,th.num{text-align:right;}
|
||||
.nota{font-size:10pt; font-style:italic; color:#444; margin-top:6px;}
|
||||
ul,ol{margin:0 0 10px; padding-left:20px;}
|
||||
li{margin-bottom:3px;}
|
||||
.cab-table td:first-child{font-family:"Segoe UI",Arial,sans-serif; font-size:8.5pt;
|
||||
text-transform:uppercase; letter-spacing:.04em; color:#555; width:170px;}
|
||||
.art-status{font-family:"Segoe UI",Arial,sans-serif; font-size:8pt; border-radius:8px;
|
||||
padding:1px 7px; margin-left:6px; white-space:nowrap; border:1px solid #999;}
|
||||
.art-status.sim{background:#E3F2E9; color:#1E7B47;}
|
||||
.art-status.nao{background:#F8EFDB; color:#A8730B;}
|
||||
.enq-desc{font-size:10pt; color:#444;}
|
||||
h2,h3{page-break-after:avoid;} tr{page-break-inside:avoid;}
|
||||
@page{margin:18mm 15mm;}`;
|
||||
|
||||
function minArtCli(ust) {
|
||||
const faixas = statusApp?.regras?.artefatos_minimos || [];
|
||||
const f = faixas.find((b) => b.ust_max === null || ust <= b.ust_max);
|
||||
return f ? f.artefatos : 1;
|
||||
}
|
||||
|
||||
function documentoHtml(sdX, rascunho) {
|
||||
const cab = sdX.cabecalho || {};
|
||||
const E = sdX.entregaveis || [];
|
||||
const infoItem = (i) => statusApp?.regras?.itens?.[i] || {};
|
||||
const soma = E.reduce((a, e) => a + (e.ust || 0), 0);
|
||||
const porItem = {};
|
||||
E.forEach((e) => (porItem[e.item_tr] = (porItem[e.item_tr] || 0) + (e.ust || 0)));
|
||||
const multi = Object.keys(porItem).length > 1;
|
||||
|
||||
let quadro = E.map((e) =>
|
||||
`<tr><td class="num">${e.n ?? ""}</td><td>${esc(e.nome)}</td><td>${esc(e.tipo || "")}</td>
|
||||
<td>${esc(e.item_tr || "")}</td><td class="num">${e.semanas ?? ""}</td>
|
||||
<td class="num">${e.timebox_h ?? ""}h</td><td class="num">${e.ust ?? ""}</td>
|
||||
<td>${fmtD(e.periodo?.inicio)} – ${fmtD(e.periodo?.fim)}</td></tr>`
|
||||
).join("");
|
||||
if (multi)
|
||||
quadro += Object.keys(porItem).sort()
|
||||
.map((i) => `<tr class="subtotal"><td colspan="6">Subtotal ${esc(i)}</td><td class="num">${porItem[i]}</td><td></td></tr>`)
|
||||
.join("");
|
||||
quadro += `<tr class="total"><td colspan="6">TOTAL</td><td class="num">${soma}</td><td></td></tr>`;
|
||||
|
||||
const notas = E.filter((e) => (e.nota_paralelismo || "").trim())
|
||||
.map((e) => `<p class="nota"><strong>Nota de paralelismo (E${e.n}):</strong> ${esc(e.nota_paralelismo)}</p>`)
|
||||
.join("");
|
||||
|
||||
const det = E.map((e) =>
|
||||
`<h3>E${e.n} · ${esc(e.tipo || "")} — ${esc(e.nome)} (${e.ust ?? "?"} UST)</h3>
|
||||
<p><strong>Resumo:</strong> ${esc(e.resumo || "")}</p>
|
||||
<p style="margin-bottom:4px"><strong>Atividades:</strong></p>
|
||||
<ol>${(e.atividades || []).map((a) => `<li>${esc(a)}</li>`).join("")}</ol>
|
||||
<p style="margin-bottom:4px"><strong>Artefatos</strong> (mínimo ${minArtCli(e.ust || 0)} pela faixa de UST):</p>
|
||||
<ul>${(e.artefatos || []).map((a) =>
|
||||
`<li>${esc(a.nome)}${a.data ? ` — ${fmtD(a.data)}` : ""}
|
||||
<span class="art-status ${a.confirmado_pelo_usuario ? "sim" : "nao"}">${a.confirmado_pelo_usuario ? "confirmado" : "a confirmar"}</span></li>`
|
||||
).join("")}</ul>
|
||||
<p style="margin-bottom:4px"><strong>Critérios de aceite:</strong></p>
|
||||
<ul>${(e.criterios_aceite || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>`
|
||||
).join("");
|
||||
|
||||
const enq = (sdX.enquadramento_tr || []).map((q) => {
|
||||
const inf = infoItem(q.item_tr);
|
||||
return `<h3>Item ${inf.numero ?? "?"} — “${esc(q.titulo_literal)}” (CATMAS ${esc(inf.catmas || "—")}) — entregáveis ${(q.entregaveis_n || []).join(", ")}</h3>
|
||||
<p class="enq-desc">${esc(q.descricao_item || "")}</p>
|
||||
<ul>${(q.aderencia || []).map((b) => `<li>${esc(b)}</li>`).join("")}</ul>`;
|
||||
}).join("");
|
||||
|
||||
const titulo = esc(cab.numero_sd || "SD") + " — " + esc(cab.projeto || "Solicitação de Demanda");
|
||||
return `<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8"><title>${titulo}</title>` +
|
||||
`<style>${CSS_IMPRESSAO}</style></head><body>` +
|
||||
`<h1>${esc(cab.titulo || cab.projeto || "Solicitação de Demanda")}</h1>` +
|
||||
`<div class="doc-sub">Solicitação de Demanda · ${esc(cab.numero_sd || "SD-__")} · ${esc(cab.versao || "V1")} · RMDS / SES-MG</div>` +
|
||||
(rascunho ? `<div class="marca-rascunho">RASCUNHO — há pendências bloqueantes abertas</div>` : "") +
|
||||
`<table class="cab-table">
|
||||
<tr><td>Projeto</td><td>${esc(cab.projeto || "")}</td></tr>
|
||||
<tr><td>Data de abertura</td><td>${fmtD(cab.data_abertura)}</td></tr>
|
||||
<tr><td>Período de execução</td><td>${fmtD(cab.periodo_execucao?.inicio)} a ${fmtD(cab.periodo_execucao?.fim)}</td></tr>
|
||||
<tr><td>Itens do TR</td><td>${(cab.itens_tr || []).map(esc).join(" e ")}</td></tr>
|
||||
<tr><td>Total</td><td>${soma} UST${cab.total_ust_extenso ? ` (${esc(cab.total_ust_extenso)})` : ""}</td></tr>
|
||||
<tr><td>Prazo de execução</td><td>${esc(cab.prazo_execucao_semanas ?? "—")} semanas (calendário)</td></tr>
|
||||
<tr><td>Entregáveis</td><td>${E.length}</td></tr>
|
||||
</table>
|
||||
<h2>1. Objetivo</h2><p>${esc(sdX.objetivo || "")}</p>
|
||||
<h2>2. Contexto e escopo</h2>${(sdX.contexto || []).map((p) => `<p>${esc(p)}</p>`).join("")}
|
||||
<h2>3. Quadro de entregáveis</h2><table>
|
||||
<tr><th class="num">#</th><th>Entregável</th><th>Tipo</th><th>Item</th><th class="num">Sem.</th><th class="num">Timebox</th><th class="num">UST</th><th>Período</th></tr>
|
||||
${quadro}</table>${notas}
|
||||
<h2>4. Detalhamento por entregável</h2>${det}
|
||||
<h2>5. Enquadramento no TR</h2>${enq}` +
|
||||
`<scr` + `ipt>window.addEventListener("load",()=>setTimeout(()=>window.print(),350));</scr` + `ipt></body></html>`;
|
||||
}
|
||||
|
||||
function abrirDocumento(sdX, rascunho) {
|
||||
const html = documentoHtml(sdX, rascunho);
|
||||
try {
|
||||
const w = window.open("", "_blank");
|
||||
if (w && w.document) {
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
return;
|
||||
}
|
||||
} catch { /* pop-up bloqueado */ }
|
||||
const nome = (sdX.cabecalho?.numero_sd || "sd").toLowerCase().replace(/[^a-z0-9-]+/g, "-") + "-impressao.html";
|
||||
const blob = new Blob([html], { type: "text/html" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = nome;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
$("btnDocumento").addEventListener("click", async () => {
|
||||
if (modoExterno) {
|
||||
if (externo?.exportado) abrirDocumento(externo.exportado, (externo.bloqueios || []).length > 0);
|
||||
return;
|
||||
}
|
||||
if (!sdAtual?.id) return;
|
||||
const r = await fetch(`/api/sds/${sdAtual.id}/export?forcar=1`);
|
||||
if (exigirLogin(r)) return;
|
||||
if (!r.ok) { alert("Não foi possível gerar o documento."); return; }
|
||||
abrirDocumento(await r.json(), ultimosBloqueios.length > 0);
|
||||
});
|
||||
|
||||
// ---------- Validar JSON externo ----------
|
||||
function mostrarValidar() {
|
||||
esconderTodas();
|
||||
$("telaValidar").hidden = false;
|
||||
modoExterno = true;
|
||||
sdAtual = null;
|
||||
moverPainel("slotPainelValidar");
|
||||
if (externo?.exportado) renderPainel(externo.exportado, externo.validacao, externo.bloqueios);
|
||||
else resetPainel();
|
||||
}
|
||||
|
||||
async function validarExterno(lerTextarea = true) {
|
||||
const erroBox = $("validarErro");
|
||||
erroBox.hidden = true;
|
||||
let sdIn = externo?.sd;
|
||||
if (lerTextarea) {
|
||||
try {
|
||||
sdIn = JSON.parse($("jsonExterno").value);
|
||||
} catch (e) {
|
||||
erroBox.textContent = "JSON inválido: " + e.message;
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const r = await fetch("/api/validar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd: sdIn }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
erroBox.textContent = d.erro || "Erro ao validar.";
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
externo = { sd: sdIn, exportado: d.exportado, validacao: d.validacao, bloqueios: d.bloqueios };
|
||||
renderPainel(d.exportado, d.validacao, d.bloqueios);
|
||||
}
|
||||
|
||||
$("btnValidarJson").addEventListener("click", () => validarExterno(true));
|
||||
|
||||
$("btnImportarJson").addEventListener("click", () => $("arquivoJson").click());
|
||||
$("arquivoJson").addEventListener("change", async (ev) => {
|
||||
const arquivo = ev.target.files?.[0];
|
||||
ev.target.value = ""; // permite reimportar o mesmo arquivo depois
|
||||
if (!arquivo) return;
|
||||
const erroBox = $("validarErro");
|
||||
if (arquivo.size > 2 * 1024 * 1024) {
|
||||
erroBox.textContent = "Arquivo muito grande (máx. 2 MB).";
|
||||
erroBox.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const texto = await arquivo.text();
|
||||
JSON.parse(texto); // valida a sintaxe antes de preencher
|
||||
$("jsonExterno").value = texto;
|
||||
erroBox.hidden = true;
|
||||
await validarExterno(true); // já valida a SD importada
|
||||
} catch (e) {
|
||||
erroBox.textContent = `O arquivo "${arquivo.name}" não contém JSON válido: ${e.message}`;
|
||||
erroBox.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("btnLimparJson").addEventListener("click", () => {
|
||||
$("jsonExterno").value = "";
|
||||
externo = null;
|
||||
$("validarErro").hidden = true;
|
||||
resetPainel();
|
||||
});
|
||||
|
||||
$("btnVoltarValidar").addEventListener("click", () => { location.hash = "#/"; });
|
||||
|
||||
// ---------- Alimentar base de conhecimento (upload de SD real) ----------
|
||||
|
||||
function mostrarBase() {
|
||||
esconderTodas();
|
||||
$("telaBase").hidden = false;
|
||||
modoExterno = false;
|
||||
sdAtual = null;
|
||||
}
|
||||
|
||||
$("btnVoltarBase").addEventListener("click", () => { location.hash = "#/"; });
|
||||
$("btnEscolherDoc").addEventListener("click", () => $("arquivoDoc").click());
|
||||
|
||||
function baseErro(msg) {
|
||||
const box = $("baseErro");
|
||||
box.textContent = msg || "";
|
||||
box.hidden = !msg;
|
||||
}
|
||||
|
||||
$("arquivoDoc").addEventListener("change", async (ev) => {
|
||||
const arquivo = ev.target.files?.[0];
|
||||
ev.target.value = ""; // permite reenviar o mesmo arquivo
|
||||
if (!arquivo) return;
|
||||
baseErro("");
|
||||
$("baseOk").hidden = true;
|
||||
const ext = (arquivo.name.toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || "";
|
||||
if (!["pdf", "md", "txt", "markdown"].includes(ext)) {
|
||||
baseErro(`Formato .${ext || "?"} não suportado — envie .pdf, .md ou .txt (para .docx, exporte como PDF).`);
|
||||
return;
|
||||
}
|
||||
if (arquivo.size > 15 * 1024 * 1024) {
|
||||
baseErro("Arquivo muito grande (máx. 15 MB).");
|
||||
return;
|
||||
}
|
||||
$("btnEscolherDoc").disabled = true;
|
||||
$("baseExtraindo").hidden = false;
|
||||
$("baseRevisao").hidden = true;
|
||||
try {
|
||||
const base64 = await new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result).split(",")[1] || "");
|
||||
fr.onerror = () => reject(new Error("Falha ao ler o arquivo."));
|
||||
fr.readAsDataURL(arquivo);
|
||||
});
|
||||
const r = await fetch("/api/base/extrair", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nome: arquivo.name, base64, modelo: $("selModeloBase").value }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) { baseErro(d.erro || "Erro na extração."); return; }
|
||||
renderRevisaoBase(d);
|
||||
if (d.uso) {
|
||||
const custo = ((d.uso.input_tokens || 0) * PRECO.input + (d.uso.output_tokens || 0) * PRECO.output) / 1e6;
|
||||
$("baseUso").textContent =
|
||||
`extração: ${(d.uso.input_tokens || 0).toLocaleString("pt-BR")} tokens lidos · ` +
|
||||
`${(d.uso.output_tokens || 0).toLocaleString("pt-BR")} gerados · custo estimado US$ ${custo.toFixed(3)} ` +
|
||||
`(estimativa válida para o modelo Opus)`;
|
||||
}
|
||||
} catch (e) {
|
||||
baseErro("Erro na extração: " + e.message);
|
||||
} finally {
|
||||
$("btnEscolherDoc").disabled = false;
|
||||
$("baseExtraindo").hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
function renderRevisaoBase(d) {
|
||||
$("baseRevisao").hidden = false;
|
||||
$("baseSdJson").value = JSON.stringify(d.sd || {}, null, 2);
|
||||
renderConferenciaBase(d.conferencia || []);
|
||||
|
||||
const obs = d.observacoes || [];
|
||||
$("baseObsBloco").hidden = !obs.length;
|
||||
$("baseObs").innerHTML = obs.map((o) => `<li>${esc(o)}</li>`).join("");
|
||||
|
||||
$("baseCaps").innerHTML = (d.capacidades || []).length
|
||||
? (d.capacidades || []).map((c, i) => `
|
||||
<div class="cap-card" data-i="${i}">
|
||||
<input type="checkbox" checked aria-label="Incluir capacidade">
|
||||
<div class="cap-main">
|
||||
<input type="text" class="cap-titulo" value="${esc(c.titulo || "")}" aria-label="Título da capacidade">
|
||||
<textarea class="cap-desc" rows="3" aria-label="Descrição da capacidade">${esc(c.descricao || "")}</textarea>
|
||||
<div class="cap-meta">
|
||||
<input type="text" class="cap-item" value="${esc(c.item_tr || "")}" aria-label="Item do TR" placeholder="item do TR (ex.: I-02)">
|
||||
<input type="text" class="cap-tags" value="${esc((c.tags || []).join(", "))}" aria-label="Tags" placeholder="tags separadas por vírgula">
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join("")
|
||||
: '<p class="hint">Nenhuma capacidade reutilizável identificada no documento.</p>';
|
||||
$("baseCaps").querySelectorAll(".cap-card input[type=checkbox]").forEach((cb) =>
|
||||
cb.addEventListener("change", (ev) =>
|
||||
ev.target.closest(".cap-card").classList.toggle("excluida", !ev.target.checked)
|
||||
)
|
||||
);
|
||||
$("baseRevisao").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function renderConferenciaBase(lista) {
|
||||
const temErro = lista.some((c) => c.nivel === "erro");
|
||||
$("baseConferencia").innerHTML = lista.length
|
||||
? lista.map((c) => `<div class="check-item st-${c.nivel === "erro" ? "falha" : "alerta"}">
|
||||
<span class="icon">${c.nivel === "erro" ? "✗" : "!"}</span>
|
||||
<span class="id">${c.nivel}</span><span>${esc(c.msg)}</span></div>`).join("")
|
||||
: '<div class="check-item st-ok"><span class="icon">✓</span><span class="id">ok</span><span>Nenhuma inconsistência encontrada.</span></div>';
|
||||
$("btnGravarBase").disabled = temErro;
|
||||
return temErro;
|
||||
}
|
||||
|
||||
function lerSdRevisada() {
|
||||
try {
|
||||
return JSON.parse($("baseSdJson").value);
|
||||
} catch (e) {
|
||||
baseErro("JSON do registro inválido: " + e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lerCapsRevisadas() {
|
||||
return [...$("baseCaps").querySelectorAll(".cap-card")]
|
||||
.filter((card) => card.querySelector("input[type=checkbox]").checked)
|
||||
.map((card) => ({
|
||||
titulo: card.querySelector(".cap-titulo").value.trim(),
|
||||
descricao: card.querySelector(".cap-desc").value.trim(),
|
||||
item_tr: card.querySelector(".cap-item").value.trim(),
|
||||
tags: card.querySelector(".cap-tags").value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter((c) => c.titulo);
|
||||
}
|
||||
|
||||
$("btnReconferir").addEventListener("click", async () => {
|
||||
baseErro("");
|
||||
const sd = lerSdRevisada();
|
||||
if (!sd) return;
|
||||
const r = await fetch("/api/base/conferir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
renderConferenciaBase(d.conferencia || []);
|
||||
});
|
||||
|
||||
$("btnGravarBase").addEventListener("click", async () => {
|
||||
baseErro("");
|
||||
const sd = lerSdRevisada();
|
||||
if (!sd) return;
|
||||
const caps = lerCapsRevisadas();
|
||||
if (!confirm(`Gravar ${sd.id || "a SD"} na base de conhecimento` +
|
||||
(caps.length ? ` com ${caps.length} capacidade(s)` : "") +
|
||||
"? Saldos de pool e precedentes passam a valer nas próximas entrevistas.")) return;
|
||||
$("btnGravarBase").disabled = true;
|
||||
try {
|
||||
const r = await fetch("/api/base/gravar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sd, capacidades: caps }),
|
||||
});
|
||||
if (exigirLogin(r)) return;
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
if (d.conferencia) renderConferenciaBase(d.conferencia);
|
||||
baseErro(d.erro || "Erro ao gravar.");
|
||||
return;
|
||||
}
|
||||
const okBox = $("baseOk");
|
||||
okBox.textContent = `✓ ${d.sd.id} gravada na base` +
|
||||
(d.capacidades.length ? ` com ${d.capacidades.map((c) => c.id).join(", ")}` : "") +
|
||||
". Vale a partir da próxima conversa.";
|
||||
okBox.hidden = false;
|
||||
await carregarStatus(); // atualiza próxima numeração e saldos no topo
|
||||
} finally {
|
||||
$("btnGravarBase").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function renderUso(uso) {
|
||||
if (!uso) return;
|
||||
const custo =
|
||||
(uso.input_tokens * PRECO.input +
|
||||
uso.output_tokens * PRECO.output +
|
||||
uso.cache_read_input_tokens * PRECO.cache_read +
|
||||
uso.cache_creation_input_tokens * PRECO.cache_write) / 1e6;
|
||||
$("usoBox").textContent =
|
||||
`tokens: ${uso.input_tokens.toLocaleString("pt-BR")} in · ${uso.output_tokens.toLocaleString("pt-BR")} out · ` +
|
||||
`cache ${uso.cache_read_input_tokens.toLocaleString("pt-BR")} lidos / ${uso.cache_creation_input_tokens.toLocaleString("pt-BR")} gravados · ` +
|
||||
`custo estimado US$ ${custo.toFixed(3)} (estimativa válida para o modelo Opus)`;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Redator de SD — RMDS / SES-MG</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📋</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="appbar">
|
||||
<h1>Redator de SD</h1>
|
||||
<span class="sub">RMDS · PE 29/2025 — SES-MG × Isis Saúde</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="statusChip" class="chip-status">carregando…</span>
|
||||
<a href="#/" id="navHome" class="btn btn-sm">Nova SD</a>
|
||||
<a href="#/validar" id="navValidar" class="btn btn-sm">Validar JSON</a>
|
||||
<a href="#/base" id="navBase" class="btn btn-sm">Base de Conhecimento</a>
|
||||
<button id="btnTema" class="btn btn-sm" type="button" title="Tema">🌗</button>
|
||||
<button id="btnSair" class="btn btn-sm" hidden>Sair</button>
|
||||
</header>
|
||||
|
||||
<!-- Aviso de configuração -->
|
||||
<div id="avisoConfig" class="aviso" hidden>
|
||||
⚠ Chave de API não configurada. Copie <code>app\.env.example</code> para <code>app\.env</code>,
|
||||
preencha <code>ANTHROPIC_API_KEY</code> e reinicie o servidor.
|
||||
</div>
|
||||
|
||||
<!-- Login (usuário único) -->
|
||||
<main id="telaLogin" hidden>
|
||||
<form id="formLogin" class="panel login-box">
|
||||
<h2>Entrar</h2>
|
||||
<label>Usuário<br><input type="text" id="loginUsuario" autocomplete="username" required></label>
|
||||
<label>Senha<br><input type="password" id="loginSenha" autocomplete="current-password" required></label>
|
||||
<div id="loginErro" class="login-erro" hidden></div>
|
||||
<button type="submit" class="btn btn-primary" id="btnLogin">Entrar</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<!-- Tela inicial: lista + nova demanda -->
|
||||
<main id="telaHome" hidden>
|
||||
<section class="panel nova-demanda">
|
||||
<h2>Nova SD</h2>
|
||||
<p class="hint">Cole a demanda do jeito que ela chegou — trecho de ata, e-mail encaminhado, mensagem.
|
||||
Não precisa organizar. O redator lê, compara com a base e pergunta só o que faltar.</p>
|
||||
<textarea id="demandaInput" rows="5" placeholder="Ex.: a secretaria quer implementar um sistema de..."></textarea>
|
||||
<div class="btnrow">
|
||||
<button id="btnIniciar" class="btn btn-primary">Iniciar entrevista</button>
|
||||
<label class="sel-modelo">Modelo
|
||||
<select id="selModelo"></select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint" style="margin:8px 0 0">O modelo fica fixo para a SD inteira (trocar no meio
|
||||
descartaria o cache da conversa). Lista configurável em <code>LLM_MODELOS</code> no .env.</p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>SDs em andamento</h2>
|
||||
<div id="listaSds" class="lista-sds"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Saldo dos pools de UST</h2>
|
||||
<div id="saldosBox" class="saldos"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Tela da entrevista: chat + painel -->
|
||||
<main id="telaConversa" class="conversa" hidden>
|
||||
<section class="coluna-chat">
|
||||
<div id="mensagens" class="mensagens"></div>
|
||||
<div id="opcoes" class="opcoes"></div>
|
||||
<form id="formMsg" class="form-msg">
|
||||
<textarea id="msgInput" rows="2" placeholder="Responda ao redator… (Enter envia, Shift+Enter quebra linha)"></textarea>
|
||||
<button type="submit" class="btn btn-primary" id="btnEnviarMsg">Enviar</button>
|
||||
</form>
|
||||
<div id="usoBox" class="uso"></div>
|
||||
</section>
|
||||
<aside class="coluna-painel" id="slotPainelConversa">
|
||||
<div id="painel">
|
||||
<div class="painel-vazio" id="painelVazio">O painel do documento aparece aqui assim que o redator
|
||||
registrar os primeiros campos da SD.</div>
|
||||
<div id="painelConteudo" hidden>
|
||||
<section class="pbloco" id="pResumo"></section>
|
||||
<section class="pbloco">
|
||||
<h3>Quadro de entregáveis</h3>
|
||||
<div id="pQuadro" class="twrap"></div>
|
||||
</section>
|
||||
<section class="pbloco">
|
||||
<h3>Trava de integridade — artefatos</h3>
|
||||
<div id="pArtefatos"></div>
|
||||
</section>
|
||||
<section class="pbloco">
|
||||
<h3>Checklist de validação</h3>
|
||||
<div id="pChecklist"></div>
|
||||
</section>
|
||||
<section class="pbloco">
|
||||
<h3>Pendências</h3>
|
||||
<ul id="pPendencias" class="pend-list"></ul>
|
||||
</section>
|
||||
<section class="pbloco" id="pExport">
|
||||
<h3>Fechamento</h3>
|
||||
<div id="pBloqueios" class="lock-msg"></div>
|
||||
<div class="btnrow">
|
||||
<button class="btn" id="btnDocumento">Documento / imprimir (PDF)</button>
|
||||
<button class="btn" id="btnBaixar">Baixar .json</button>
|
||||
<button class="btn btn-primary" id="btnEnviarSd">Marcar como enviada</button>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:8px">O documento abre em uma aba pronta para imprimir
|
||||
(Ctrl+P → salvar como PDF). Enquanto houver pendência bloqueante, ele sai com marca de rascunho.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- Validação de JSON externo -->
|
||||
<main id="telaValidar" class="conversa" hidden>
|
||||
<section class="coluna-chat">
|
||||
<div class="panel">
|
||||
<h2>Validar JSON externo</h2>
|
||||
<p class="hint">Cole aqui o JSON de uma SD que não nasceu neste app (ex.: redigida no Claude ou
|
||||
recebida de um colega). O app recalcula UST e datas, roda o checklist do contrato e libera o
|
||||
documento para impressão — sem salvar nada.</p>
|
||||
<textarea id="jsonExterno" rows="14" spellcheck="false"
|
||||
placeholder='Cole o JSON da SD (formato schema-sd.json)…'></textarea>
|
||||
<div id="validarErro" class="login-erro" hidden></div>
|
||||
<div class="btnrow" style="margin-top:10px">
|
||||
<button class="btn btn-primary" id="btnValidarJson">Validar</button>
|
||||
<button class="btn" id="btnImportarJson">Importar JSON</button>
|
||||
<button class="btn" id="btnLimparJson">Limpar</button>
|
||||
<button class="btn" id="btnVoltarValidar">Voltar</button>
|
||||
</div>
|
||||
<input type="file" id="arquivoJson" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="coluna-painel" id="slotPainelValidar"></aside>
|
||||
</main>
|
||||
|
||||
<!-- Alimentar base de conhecimento: upload de SD real → extração → revisão → gravação -->
|
||||
<main id="telaBase" hidden>
|
||||
<section class="panel">
|
||||
<h2>Alimentar base de conhecimento</h2>
|
||||
<p class="hint">Envie o documento de uma SD real (<strong>.pdf</strong>, <strong>.md</strong> ou <strong>.txt</strong> —
|
||||
se estiver em .docx, exporte como PDF). O app lê o documento, extrai o registro estruturado
|
||||
(itens, UST por item, período, status) e sugere capacidades reutilizáveis. Nada é gravado
|
||||
sem a sua revisão abaixo.</p>
|
||||
<div class="btnrow">
|
||||
<button class="btn btn-primary" id="btnEscolherDoc">Enviar documento</button>
|
||||
<label class="sel-modelo">Modelo
|
||||
<select id="selModeloBase"></select>
|
||||
</label>
|
||||
<button class="btn" id="btnVoltarBase">Voltar</button>
|
||||
</div>
|
||||
<input type="file" id="arquivoDoc" accept=".pdf,.md,.txt,application/pdf,text/markdown,text/plain" hidden>
|
||||
<div id="baseErro" class="login-erro" hidden style="margin-top:10px"></div>
|
||||
<div id="baseExtraindo" class="hint" hidden style="margin-top:10px">⏳ Lendo o documento e extraindo o registro… isso leva de 30 segundos a 2 minutos.</div>
|
||||
<div id="baseUso" class="uso" style="margin-top:6px"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="baseRevisao" hidden>
|
||||
<h2>Revisão antes de gravar</h2>
|
||||
<div class="pbloco">
|
||||
<h3>Conferência (em código)</h3>
|
||||
<div id="baseConferencia"></div>
|
||||
</div>
|
||||
<div class="pbloco" id="baseObsBloco" hidden>
|
||||
<h3>Observações do extrator</h3>
|
||||
<ul id="baseObs" class="pend-list"></ul>
|
||||
</div>
|
||||
<div class="pbloco">
|
||||
<h3>Registro da SD (edite se necessário e reconfira)</h3>
|
||||
<textarea id="baseSdJson" rows="16" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="pbloco">
|
||||
<h3>Capacidades sugeridas (desmarque as que não devem entrar)</h3>
|
||||
<div id="baseCaps"></div>
|
||||
</div>
|
||||
<div class="btnrow">
|
||||
<button class="btn" id="btnReconferir">Reconferir</button>
|
||||
<button class="btn btn-primary" id="btnGravarBase">Gravar na base</button>
|
||||
</div>
|
||||
<div id="baseOk" class="lock-msg open" hidden style="margin-top:10px"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Tema: claro é o padrão; o escuro vale quando o usuário escolhe (data-theme="dark")
|
||||
ou quando o sistema está escuro e o usuário não forçou o claro (data-theme="light").
|
||||
O botão 🌗 do topo alterna automático → claro → escuro (persistido em localStorage). */
|
||||
:root{
|
||||
color-scheme:light;
|
||||
--bg:#F2F6F5; --surface:#FFFFFF; --ink:#1C2A2D; --muted:#5C6B6E;
|
||||
--line:#D7E1DF; --accent:#0B6B70; --accent-soft:#E1EFEF; --accent-ink:#FFFFFF;
|
||||
--ok:#1E7B47; --ok-bg:#E3F2E9; --warn:#A8730B; --warn-bg:#F8EFDB;
|
||||
--err:#B3362B; --err-bg:#F9E4E1; --pend:#5E6E78; --pend-bg:#E8EDF0;
|
||||
--shadow:0 1px 3px rgba(28,42,45,.08);
|
||||
--sans:"Segoe UI",system-ui,-apple-system,sans-serif;
|
||||
--mono:Consolas,"Cascadia Mono",ui-monospace,monospace;
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
color-scheme:dark;
|
||||
--bg:#0E191B; --surface:#152325; --ink:#E3EDEB; --muted:#94A6A4;
|
||||
--line:#25393C; --accent:#56C4CA; --accent-soft:#123B3E; --accent-ink:#08282A;
|
||||
--ok:#54C88A; --ok-bg:#123626; --warn:#E2AC42; --warn-bg:#3A2D0F;
|
||||
--err:#E3705F; --err-bg:#41201C; --pend:#9AA8B2; --pend-bg:#22313A;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.35);
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
color-scheme:dark;
|
||||
--bg:#0E191B; --surface:#152325; --ink:#E3EDEB; --muted:#94A6A4;
|
||||
--line:#25393C; --accent:#56C4CA; --accent-soft:#123B3E; --accent-ink:#08282A;
|
||||
--ok:#54C88A; --ok-bg:#123626; --warn:#E2AC42; --warn-bg:#3A2D0F;
|
||||
--err:#E3705F; --err-bg:#41201C; --pend:#9AA8B2; --pend-bg:#22313A;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
[hidden]{display:none !important}
|
||||
html,body{height:100%}
|
||||
body{margin:0; background:var(--bg); color:var(--ink); font-family:var(--sans); line-height:1.5; display:flex; flex-direction:column;}
|
||||
button{font-family:var(--sans); cursor:pointer;}
|
||||
button:focus-visible, a:focus-visible, textarea:focus-visible, input:focus-visible{outline:2px solid var(--accent); outline-offset:2px;}
|
||||
textarea, select, input[type=date], input[type=text]{font-family:var(--sans); font-size:14px; background:var(--surface); color:var(--ink); border:1px solid var(--line); border-radius:6px; padding:8px 10px;}
|
||||
.sel-modelo{display:inline-flex; align-items:center; gap:8px; font-size:13px; color:var(--muted);}
|
||||
.sel-modelo select{font-family:var(--mono); font-size:12.5px; padding:7px 8px;}
|
||||
textarea{width:100%; resize:vertical;}
|
||||
|
||||
.appbar{background:var(--surface); border-bottom:1px solid var(--line); padding:10px 20px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;}
|
||||
.appbar h1{font-size:16px; margin:0;}
|
||||
.appbar .sub{color:var(--muted); font-size:12px;}
|
||||
.appbar .spacer{flex:1;}
|
||||
.chip-status{font-size:12px; color:var(--muted); font-family:var(--mono);}
|
||||
|
||||
.aviso{background:var(--warn-bg); color:var(--warn); padding:10px 20px; font-size:14px;}
|
||||
.aviso code{font-family:var(--mono);}
|
||||
|
||||
.btn{border:1px solid var(--line); background:var(--surface); color:var(--ink); border-radius:6px; padding:8px 16px; font-size:14px; font-weight:600; text-decoration:none; display:inline-block;}
|
||||
.btn:hover{border-color:var(--accent);}
|
||||
.btn-sm{padding:4px 12px; font-size:13px;}
|
||||
.btn-primary{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
|
||||
.btn-primary:hover{filter:brightness(1.08);}
|
||||
.btn:disabled{opacity:.45; cursor:not-allowed;}
|
||||
.btnrow{display:flex; gap:10px; flex-wrap:wrap;}
|
||||
|
||||
.panel{background:var(--surface); border:1px solid var(--line); border-radius:8px; box-shadow:var(--shadow); padding:16px 20px;}
|
||||
.panel h2{font-size:14px; margin:0 0 10px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted);}
|
||||
.hint{font-size:13px; color:var(--muted); margin:0 0 10px;}
|
||||
|
||||
/* --- Login --- */
|
||||
#telaLogin{flex:1; display:flex; align-items:center; justify-content:center; padding:20px;}
|
||||
.login-box{width:100%; max-width:340px; display:flex; flex-direction:column; gap:12px;}
|
||||
.login-box label{font-size:13px; color:var(--muted);}
|
||||
.login-box input{width:100%; margin-top:4px;}
|
||||
.login-erro{background:var(--err-bg); color:var(--err); border-radius:6px; padding:8px 12px; font-size:13px;}
|
||||
|
||||
/* --- Home --- */
|
||||
#telaHome, #telaBase{max-width:860px; width:100%; margin:0 auto; padding:20px; display:flex; flex-direction:column; gap:16px;}
|
||||
.nova-demanda textarea{margin-bottom:10px;}
|
||||
.lista-sds{display:flex; flex-direction:column; gap:6px;}
|
||||
.sd-item{display:flex; gap:12px; align-items:baseline; padding:8px 10px; border:1px solid var(--line); border-radius:6px; text-decoration:none; color:var(--ink);}
|
||||
.sd-item:hover{border-color:var(--accent); background:var(--accent-soft);}
|
||||
.sd-item .num{font-family:var(--mono); font-size:12px; color:var(--accent); white-space:nowrap;}
|
||||
.sd-item .tit{flex:1; font-size:14px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||
.sd-item .st{font-size:11px; border-radius:10px; padding:1px 8px;}
|
||||
.sd-item .btn-excluir{border:1px solid var(--line); background:var(--surface); color:var(--muted);
|
||||
border-radius:6px; font-size:12px; line-height:1; padding:4px 8px; align-self:center; flex:0 0 auto;}
|
||||
.sd-item .btn-excluir:hover{border-color:var(--err); color:var(--err); background:var(--err-bg);}
|
||||
.st-rascunho{background:var(--warn-bg); color:var(--warn);}
|
||||
.st-enviada{background:var(--ok-bg); color:var(--ok);}
|
||||
.saldos{display:flex; gap:10px; flex-wrap:wrap;}
|
||||
.saldo-chip{border:1px solid var(--line); border-radius:8px; padding:8px 14px; min-width:130px;}
|
||||
.saldo-chip .label{font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted);}
|
||||
.saldo-chip .value{font-size:17px; font-weight:700; font-variant-numeric:tabular-nums;}
|
||||
.saldo-chip .sub{font-size:11px; color:var(--muted);}
|
||||
|
||||
/* --- Conversa --- */
|
||||
.conversa{flex:1; display:grid; grid-template-columns:minmax(0,1fr) 420px; gap:0; min-height:0;}
|
||||
@media (max-width: 980px){ .conversa{grid-template-columns:1fr;} .coluna-painel{border-left:none; border-top:1px solid var(--line);} }
|
||||
.coluna-chat{display:flex; flex-direction:column; min-height:0; padding:16px 20px; gap:10px;}
|
||||
.mensagens{flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px; padding-right:4px; min-height:200px;}
|
||||
.msg{max-width:92%; padding:10px 14px; border-radius:10px; font-size:14px;}
|
||||
.msg.user{align-self:flex-end; background:var(--accent-soft); border:1px solid var(--line); white-space:pre-wrap;}
|
||||
.msg.assistant{align-self:flex-start; background:var(--surface); border:1px solid var(--line); box-shadow:var(--shadow);}
|
||||
.msg.assistant p{margin:0 0 8px;} .msg.assistant p:last-child{margin-bottom:0;}
|
||||
.msg.assistant ul,.msg.assistant ol{margin:4px 0 8px; padding-left:20px;}
|
||||
.msg.assistant li{margin-bottom:2px;}
|
||||
.msg.assistant h1,.msg.assistant h2,.msg.assistant h3{font-size:14.5px; margin:10px 0 4px;}
|
||||
.msg.assistant code{font-family:var(--mono); font-size:12.5px; background:var(--bg); padding:0 4px; border-radius:4px;}
|
||||
.msg.assistant strong{color:var(--accent);}
|
||||
.msg .digitando{display:inline-block; animation:pulse 1s infinite;}
|
||||
.msg .pensando{display:inline-block; color:var(--muted); font-style:italic; font-size:13px; animation:pulse 1.4s infinite;}
|
||||
@keyframes pulse{50%{opacity:.3}}
|
||||
@media (prefers-reduced-motion: reduce){ .msg .digitando{animation:none;} }
|
||||
.opcoes{display:flex; flex-direction:column; gap:6px;}
|
||||
.opcoes button{text-align:left; background:var(--surface); border:1px solid var(--accent); color:var(--ink); border-radius:8px; padding:8px 14px; font-size:13.5px;}
|
||||
.opcoes button:hover{background:var(--accent-soft);}
|
||||
.form-msg{display:flex; gap:8px; align-items:flex-end;}
|
||||
.form-msg textarea{flex:1;}
|
||||
.uso{font-size:11.5px; color:var(--muted); font-family:var(--mono);}
|
||||
|
||||
/* --- Painel --- */
|
||||
.coluna-painel{border-left:1px solid var(--line); background:var(--surface); overflow-y:auto; padding:16px 18px;}
|
||||
.painel-vazio{color:var(--muted); font-size:13.5px; padding:30px 10px; text-align:center;}
|
||||
.pbloco{margin-bottom:18px;}
|
||||
.pbloco h3{font-size:12px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin:0 0 8px;}
|
||||
.resumo-chips{display:flex; gap:8px; flex-wrap:wrap; margin-bottom:6px;}
|
||||
.rchip{border:1px solid var(--line); border-radius:8px; padding:6px 12px;}
|
||||
.rchip .label{font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted);}
|
||||
.rchip .value{font-size:15px; font-weight:700; font-variant-numeric:tabular-nums;}
|
||||
.ptitulo{font-size:14.5px; font-weight:700; margin-bottom:8px;}
|
||||
.twrap{overflow-x:auto;}
|
||||
table.quadro{border-collapse:collapse; width:100%; font-size:12px; font-variant-numeric:tabular-nums;}
|
||||
table.quadro th, table.quadro td{border:1px solid var(--line); padding:4px 7px; text-align:left; vertical-align:top;}
|
||||
table.quadro th{font-size:10.5px; text-transform:uppercase; letter-spacing:.04em; background:var(--accent-soft);}
|
||||
table.quadro td.num{text-align:right;}
|
||||
table.quadro tr.total td{font-weight:700; border-top:2px solid var(--accent);}
|
||||
table.quadro tr.subtotal td{font-weight:600; background:var(--accent-soft);}
|
||||
|
||||
.check-item{display:grid; grid-template-columns:18px 56px 1fr; gap:6px; padding:4px 0; border-top:1px solid var(--line); font-size:12.5px; align-items:start;}
|
||||
.check-item:first-child{border-top:none;}
|
||||
.check-item .icon{font-weight:700; text-align:center;}
|
||||
.check-item .id{font-family:var(--mono); font-size:10.5px; color:var(--muted); padding-top:2px;}
|
||||
.check-item .obs{display:block; color:var(--muted); font-size:11.5px;}
|
||||
.st-ok .icon{color:var(--ok);} .st-alerta .icon{color:var(--warn);}
|
||||
.st-falha .icon{color:var(--err);} .st-pendente .icon{color:var(--pend);}
|
||||
|
||||
.art-entr{margin-bottom:10px;}
|
||||
.art-entr h4{font-size:12px; margin:0 0 4px;}
|
||||
.art-row{display:flex; gap:8px; align-items:flex-start; padding:5px 8px; border-radius:6px; font-size:12.5px; margin-bottom:3px;}
|
||||
.art-row.confirmed{background:var(--ok-bg);}
|
||||
.art-row.unconfirmed{background:var(--warn-bg);}
|
||||
.art-row input[type=checkbox]{margin-top:3px; accent-color:var(--accent); width:15px; height:15px; flex:0 0 auto;}
|
||||
.art-row .art-main{flex:1; min-width:0;}
|
||||
.art-row label{cursor:pointer; display:block;}
|
||||
.art-row input[type=date]{font-family:var(--mono); font-size:11.5px; margin-top:3px; padding:2px 6px;}
|
||||
.art-row .flag{font-size:11px; margin-top:2px;}
|
||||
.flag.bad{color:var(--err);} .flag.miss{color:var(--warn);}
|
||||
|
||||
/* --- Alimentar base --- */
|
||||
.cap-card{border:1px solid var(--line); border-radius:8px; padding:10px 12px; margin-bottom:8px;
|
||||
display:flex; gap:10px; align-items:flex-start;}
|
||||
.cap-card input[type=checkbox]{margin-top:4px; accent-color:var(--accent); width:16px; height:16px; flex:0 0 auto;}
|
||||
.cap-card .cap-main{flex:1; min-width:0; display:flex; flex-direction:column; gap:6px;}
|
||||
.cap-card input[type=text]{width:100%; font-weight:600;}
|
||||
.cap-card textarea{font-size:13px;}
|
||||
.cap-card .cap-meta{display:flex; gap:8px; flex-wrap:wrap;}
|
||||
.cap-card .cap-meta input{flex:1; min-width:120px; font-size:12.5px; font-family:var(--mono);}
|
||||
.cap-card.excluida{opacity:.45;}
|
||||
|
||||
.pend-list{margin:0; padding-left:18px; font-size:12.5px; display:flex; flex-direction:column; gap:5px;}
|
||||
.lock-msg{font-size:12.5px; border-radius:6px; padding:8px 11px; margin-bottom:10px;}
|
||||
.lock-msg.locked{background:var(--warn-bg); color:var(--warn);}
|
||||
.lock-msg.open{background:var(--ok-bg); color:var(--ok);}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// Base de conhecimento: numeração sequencial, saldo de pools e registro de SDs enviadas.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJETO_DIR } from "./config.js";
|
||||
import { REGRAS } from "./validador.js";
|
||||
|
||||
const BASE_PATH = path.join(PROJETO_DIR, "base-conhecimento.json");
|
||||
|
||||
export function lerBase() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(BASE_PATH, "utf8"));
|
||||
} catch {
|
||||
return { sds: [], capacidades: [], convencoes: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function registrosReais(base) {
|
||||
// Ignora os registros de exemplo que vieram no esqueleto do arquivo.
|
||||
return (base.sds || []).filter((s) => !String(s.id).includes("EXEMPLO"));
|
||||
}
|
||||
|
||||
export function proximoNumero(base, numerosEmUso = []) {
|
||||
const nums = registrosReais(base)
|
||||
.map((s) => Number(String(s.id).match(/^SD-(\d+)/)?.[1]))
|
||||
.filter((n) => !isNaN(n));
|
||||
const max = Math.max(0, ...nums, ...numerosEmUso);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export function saldos(base) {
|
||||
const consumo = {};
|
||||
const consomem = new Set(
|
||||
base.convencoes?.status_que_consomem_pool || [
|
||||
"enviada", "aprovada", "em_execucao", "concluida", "faturada",
|
||||
]
|
||||
);
|
||||
registrosReais(base).forEach((s) => {
|
||||
if (!consomem.has(s.status)) return;
|
||||
Object.entries(s.ust_por_item || {}).forEach(([item, ust]) => {
|
||||
consumo[item] = (consumo[item] || 0) + (ust || 0);
|
||||
});
|
||||
});
|
||||
const out = {};
|
||||
Object.entries(REGRAS.itens).forEach(([item, def]) => {
|
||||
out[item] = {
|
||||
pool_total: def.pool_ust_total,
|
||||
consumido: consumo[item] || 0,
|
||||
saldo: def.pool_ust_total - (consumo[item] || 0),
|
||||
};
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bloco de contexto injetado na primeira mensagem de cada conversa. */
|
||||
export function contextoParaConversa(numeroProvisorio) {
|
||||
const base = lerBase();
|
||||
const s = saldos(base);
|
||||
const reais = registrosReais(base);
|
||||
const caps = (base.capacidades || []).filter((c) => !String(c.id).includes("EXEMPLO"));
|
||||
const linhas = [
|
||||
`Número provisório desta SD: SD-${numeroProvisorio} (o app confirma no envio).`,
|
||||
`Data de hoje: ${new Date().toISOString().slice(0, 10)}.`,
|
||||
"Saldo dos pools de UST (pool total − SDs que consomem pool):",
|
||||
...Object.entries(s).map(
|
||||
([i, v]) => ` ${i}: saldo ${v.saldo} UST (total ${v.pool_total}, consumido ${v.consumido})`
|
||||
),
|
||||
reais.length
|
||||
? `SDs anteriores na base: ${reais.map((r) => `${r.id} (${r.titulo}, ${r.status})`).join("; ")}`
|
||||
: "Base de SDs anteriores vazia — não cite precedentes.",
|
||||
caps.length
|
||||
? `Capacidades já entregues: ${caps.map((c) => `${c.id} (${c.titulo})`).join("; ")}`
|
||||
: "Nenhuma capacidade registrada na base — dimensione como construção do zero, mas pergunte ao usuário se algo já existe quando for relevante.",
|
||||
];
|
||||
return linhas.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Grava uma SD REAL (extraída de documento e revisada pelo usuário) na base.
|
||||
* Substitui o registro se o id já existir; capacidades novas recebem CAP-NN sequencial.
|
||||
*/
|
||||
export function gravarSdReal(sd, capacidades = []) {
|
||||
const base = lerBase();
|
||||
const registro = {
|
||||
id: sd.id,
|
||||
titulo: String(sd.titulo || "").trim(),
|
||||
projeto: String(sd.projeto || "").trim(),
|
||||
itens_tr: sd.itens_tr || Object.keys(sd.ust_por_item || {}),
|
||||
ust_total: sd.ust_total,
|
||||
ust_por_item: sd.ust_por_item || {},
|
||||
status: sd.status,
|
||||
periodo: sd.periodo || null,
|
||||
resumo: String(sd.resumo || "").trim(),
|
||||
tags: Array.isArray(sd.tags) ? sd.tags : [],
|
||||
fonte: String(sd.fonte || "").trim(),
|
||||
};
|
||||
base.sds = (base.sds || []).filter((s) => s.id !== registro.id);
|
||||
base.sds.push(registro);
|
||||
|
||||
const capsGravadas = [];
|
||||
base.capacidades = base.capacidades || [];
|
||||
let n = Math.max(
|
||||
0,
|
||||
...base.capacidades
|
||||
.map((c) => Number(String(c.id).match(/^CAP-(\d+)/)?.[1]))
|
||||
.filter((x) => !isNaN(x))
|
||||
);
|
||||
for (const c of capacidades) {
|
||||
if (!String(c?.titulo || "").trim()) continue;
|
||||
const cap = {
|
||||
id: `CAP-${String(++n).padStart(2, "0")}`,
|
||||
titulo: String(c.titulo).trim(),
|
||||
descricao: String(c.descricao || "").trim(),
|
||||
origem_sd: registro.id,
|
||||
item_tr: String(c.item_tr || "").trim(),
|
||||
status: "entregue",
|
||||
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||
};
|
||||
base.capacidades.push(cap);
|
||||
capsGravadas.push(cap);
|
||||
}
|
||||
|
||||
base.atualizado_em = new Date().toISOString().slice(0, 10);
|
||||
fs.writeFileSync(BASE_PATH, JSON.stringify(base, null, 2), "utf8");
|
||||
return { sd: registro, capacidades: capsGravadas };
|
||||
}
|
||||
|
||||
/** Acrescenta a SD enviada à base de conhecimento (uma linha por SD, como planejado). */
|
||||
export function registrarEnvio(sdExport) {
|
||||
const base = lerBase();
|
||||
const cab = sdExport.cabecalho || {};
|
||||
const porItem = {};
|
||||
(sdExport.entregaveis || []).forEach((e) => {
|
||||
porItem[e.item_tr] = (porItem[e.item_tr] || 0) + (e.ust || 0);
|
||||
});
|
||||
const registro = {
|
||||
id: cab.numero_sd,
|
||||
titulo: cab.titulo || cab.projeto || "",
|
||||
projeto: cab.projeto || "",
|
||||
itens_tr: cab.itens_tr || Object.keys(porItem),
|
||||
ust_total: cab.total_ust,
|
||||
ust_por_item: porItem,
|
||||
status: "enviada",
|
||||
periodo: cab.periodo_execucao || null,
|
||||
resumo: String(sdExport.objetivo || "").slice(0, 300),
|
||||
tags: [],
|
||||
};
|
||||
base.sds = (base.sds || []).filter((s) => s.id !== registro.id);
|
||||
base.sds.push(registro);
|
||||
base.atualizado_em = new Date().toISOString().slice(0, 10);
|
||||
fs.writeFileSync(BASE_PATH, JSON.stringify(base, null, 2), "utf8");
|
||||
return registro;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Raiz do app (D:\IasisSaude\app) e raiz do projeto (D:\IasisSaude),
|
||||
// onde vivem regras-sd.json, schema-sd.json, base-conhecimento.json e o prompt.
|
||||
export const APP_DIR = path.resolve(here, "..");
|
||||
export const PROJETO_DIR = path.resolve(APP_DIR, "..");
|
||||
export const DATA_DIR = path.join(APP_DIR, "data");
|
||||
|
||||
dotenv.config({ path: path.join(APP_DIR, ".env") });
|
||||
|
||||
const EFFORTS_VALIDOS = ["low", "medium", "high", "xhigh", "max"];
|
||||
let effort = (process.env.LLM_EFFORT || "").trim().toLowerCase();
|
||||
if (effort && !EFFORTS_VALIDOS.includes(effort)) {
|
||||
console.warn(
|
||||
`⚠ LLM_EFFORT="${process.env.LLM_EFFORT}" inválido (use ${EFFORTS_VALIDOS.join("/")}) — usando o padrão da API.`
|
||||
);
|
||||
effort = "";
|
||||
}
|
||||
|
||||
const model = process.env.LLM_MODEL || "claude-opus-5";
|
||||
|
||||
// Modelos oferecidos nos seletores da interface (Nova SD e Base de Conhecimento).
|
||||
// LLM_MODELOS: lista separada por vírgula; o LLM_MODEL padrão sempre entra.
|
||||
const modelos = [
|
||||
...new Set(
|
||||
[model, ...(process.env.LLM_MODELOS || "").split(",").map((m) => m.trim()).filter(Boolean)]
|
||||
),
|
||||
];
|
||||
|
||||
export const config = {
|
||||
provider: process.env.LLM_PROVIDER || "anthropic",
|
||||
model,
|
||||
modelos,
|
||||
apiKey: process.env.ANTHROPIC_API_KEY || "",
|
||||
effort, // "" = padrão da API (high)
|
||||
port: Number(process.env.PORT || 3344),
|
||||
usuario: process.env.APP_USUARIO || "",
|
||||
senha: process.env.APP_SENHA || "",
|
||||
};
|
||||
|
||||
export function configOk() {
|
||||
return Boolean(config.apiKey);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Extração de uma SD REAL (documento .pdf/.md/.txt) para o formato da base de conhecimento.
|
||||
// O LLM lê o documento e devolve o registro estruturado; a conferência determinística
|
||||
// roda em código; a gravação só acontece depois da revisão humana na tela.
|
||||
import { REGRAS } from "./validador.js";
|
||||
import { lerBase, saldos } from "./base.js";
|
||||
|
||||
const STATUS_SD = ["rascunho", "enviada", "aprovada", "em_execucao", "concluida", "faturada", "cancelada"];
|
||||
const STATUS_CONSOMEM = ["enviada", "aprovada", "em_execucao", "concluida", "faturada"];
|
||||
|
||||
// ---------- Ferramenta de extração (saída estruturada) ----------
|
||||
|
||||
export const TOOL_EXTRACAO = {
|
||||
name: "registrar_extracao",
|
||||
description:
|
||||
"Registra o resultado da extração do documento da SD no formato da base de conhecimento. " +
|
||||
"Chame exatamente uma vez, com todos os fatos que o documento sustenta.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["sd"],
|
||||
properties: {
|
||||
sd: {
|
||||
type: "object",
|
||||
required: ["id", "titulo", "projeto", "itens_tr", "ust_total", "ust_por_item", "status", "resumo", "tags"],
|
||||
properties: {
|
||||
id: { type: "string", pattern: "^SD-\\d+$", description: "Número oficial da SD no contrato, ex.: SD-8" },
|
||||
titulo: { type: "string" },
|
||||
projeto: { type: "string", description: "Programa/projeto a que a SD pertence, ex.: 'P2 — Vacinação'" },
|
||||
itens_tr: { type: "array", items: { type: "string", enum: Object.keys(REGRAS.itens) } },
|
||||
ust_total: { type: "integer", description: "Total de UST da SD" },
|
||||
ust_por_item: {
|
||||
type: "object",
|
||||
description: "Soma das UST do quadro de entregáveis, por item do TR. As chaves devem ser as mesmas de itens_tr e a soma deve bater com ust_total.",
|
||||
additionalProperties: { type: "integer" },
|
||||
},
|
||||
status: { type: "string", enum: STATUS_SD },
|
||||
periodo: {
|
||||
type: ["object", "null"],
|
||||
properties: {
|
||||
inicio: { type: "string", description: "AAAA-MM-DD" },
|
||||
fim: { type: "string", description: "AAAA-MM-DD" },
|
||||
},
|
||||
},
|
||||
resumo: { type: "string", description: "Resumo factual em até ~600 caracteres: o que foi entregue e o que ficou de reutilizável." },
|
||||
tags: { type: "array", items: { type: "string" }, description: "minúsculas, sem acento, singular" },
|
||||
fonte: { type: "string", description: "Nome do arquivo enviado" },
|
||||
},
|
||||
},
|
||||
capacidades: {
|
||||
type: "array",
|
||||
description: "Capacidades REUTILIZÁVEIS que esta SD entregou (integrações, motores, componentes, dicionários de dados) — aquilo que uma SD futura reaproveitaria em vez de reconstruir.",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["titulo", "descricao", "item_tr"],
|
||||
properties: {
|
||||
titulo: { type: "string" },
|
||||
descricao: { type: "string", description: "Padrão: o que existe hoje + o que uma nova SD NÃO precisa refazer." },
|
||||
item_tr: { type: "string", enum: Object.keys(REGRAS.itens) },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
observacoes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tudo que ficou ambíguo no documento ou merece revisão humana (status incerto, valores conflitantes, campos ausentes).",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function montarSystemExtracao() {
|
||||
const itens = Object.entries(REGRAS.itens)
|
||||
.map(([k, v]) => ` ${k} — item ${v.item_tr_numero} do TR, "${v.titulo_literal}" (CATMAS ${v.codigo_catmas})`)
|
||||
.join("\n");
|
||||
return [
|
||||
"Você extrai, de um documento oficial de Solicitação de Demanda (SD) do contrato PE 29/2025",
|
||||
"(programa RMDS — SES-MG × Isis Saúde), o registro estruturado para a base de conhecimento do redator de SDs.",
|
||||
"",
|
||||
"Regras de extração:",
|
||||
"- Registre APENAS fatos presentes no documento. A base só serve se for verdadeira — nunca estime valores que o documento não sustenta.",
|
||||
"- ust_por_item: some as UST do quadro de entregáveis por item do TR. A soma por item deve bater com o total da SD; confira antes de responder.",
|
||||
"- Itens do TR deste contrato:",
|
||||
itens,
|
||||
`- status: use a convenção [${STATUS_SD.join(", ")}]. Se o documento não permitir concluir o status, escolha o mais provável e registre a dúvida em observacoes.`,
|
||||
"- capacidades: só o que é genuinamente reutilizável por SDs futuras. Prefira poucas e bem descritas a muitas e vagas.",
|
||||
"- tags: minúsculas, sem acento, singular quando possível (ex.: vacinacao, integracao, dashboard).",
|
||||
"- fonte: repita o nome do arquivo informado na mensagem.",
|
||||
"",
|
||||
"Chame a ferramenta registrar_extracao exatamente uma vez. Não escreva texto além da chamada.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Monta o conteúdo da mensagem do usuário a partir do arquivo enviado (base64). */
|
||||
export function montarConteudoDocumento({ nome, base64 }) {
|
||||
const ext = String(nome || "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || "";
|
||||
const instrucao = `Arquivo: ${nome}\nExtraia o registro desta SD para a base de conhecimento e chame registrar_extracao.`;
|
||||
if (ext === "pdf") {
|
||||
return [
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } },
|
||||
{ type: "text", text: instrucao },
|
||||
];
|
||||
}
|
||||
const texto = Buffer.from(base64, "base64").toString("utf8");
|
||||
return [{ type: "text", text: `${instrucao}\n\n<documento nome="${nome}">\n${texto}\n</documento>` }];
|
||||
}
|
||||
|
||||
// ---------- Conferência determinística (roda em código, sem LLM) ----------
|
||||
|
||||
/** Devolve a lista de inconsistências: nivel "erro" bloqueia a gravação; "alerta" só avisa. */
|
||||
export function conferirExtracao(sd) {
|
||||
const avisos = [];
|
||||
const erro = (msg) => avisos.push({ nivel: "erro", msg });
|
||||
const alerta = (msg) => avisos.push({ nivel: "alerta", msg });
|
||||
if (!sd || typeof sd !== "object") {
|
||||
erro("Registro da SD ausente ou inválido.");
|
||||
return avisos;
|
||||
}
|
||||
const base = lerBase();
|
||||
|
||||
if (!/^SD-\d+$/.test(String(sd.id || ""))) erro(`id "${sd.id ?? ""}" fora do padrão SD-NN.`);
|
||||
if (!String(sd.titulo || "").trim()) erro("Título vazio.");
|
||||
|
||||
const porItem = sd.ust_por_item && typeof sd.ust_por_item === "object" ? sd.ust_por_item : {};
|
||||
const soma = Object.values(porItem).reduce((a, b) => a + (Number(b) || 0), 0);
|
||||
if (!Number.isFinite(sd.ust_total) || sd.ust_total <= 0) erro("ust_total ausente ou inválido.");
|
||||
else if (soma !== sd.ust_total)
|
||||
erro(`Soma de ust_por_item (${soma}) difere de ust_total (${sd.ust_total}).`);
|
||||
|
||||
Object.keys(porItem).forEach((i) => {
|
||||
if (!REGRAS.itens[i]) erro(`Item "${i}" em ust_por_item não existe no contrato (${Object.keys(REGRAS.itens).join(", ")}).`);
|
||||
});
|
||||
const setItens = new Set(Array.isArray(sd.itens_tr) ? sd.itens_tr : []);
|
||||
const setPor = new Set(Object.keys(porItem));
|
||||
if (setItens.size !== setPor.size || [...setItens].some((i) => !setPor.has(i)))
|
||||
erro(`itens_tr (${[...setItens].join(", ") || "vazio"}) não bate com as chaves de ust_por_item (${[...setPor].join(", ") || "vazio"}).`);
|
||||
|
||||
if (!STATUS_SD.includes(sd.status)) erro(`status "${sd.status ?? ""}" fora da convenção (${STATUS_SD.join(", ")}).`);
|
||||
|
||||
const p = sd.periodo;
|
||||
if (p?.inicio && p?.fim && p.inicio > p.fim) erro(`Período invertido: início ${p.inicio} posterior ao fim ${p.fim}.`);
|
||||
else if (!p?.inicio || !p?.fim) alerta("Período de execução incompleto — confirme início e fim.");
|
||||
|
||||
const existente = (base.sds || []).find((s) => s.id === sd.id);
|
||||
if (existente) alerta(`${sd.id} já existe na base ("${existente.titulo}") — gravar vai SUBSTITUIR esse registro.`);
|
||||
|
||||
// Impacto no saldo dos pools (descontando o que o registro antigo já consumia).
|
||||
const consomem = new Set(base.convencoes?.status_que_consomem_pool || STATUS_CONSOMEM);
|
||||
if (consomem.has(sd.status)) {
|
||||
const s = saldos(base);
|
||||
Object.entries(porItem).forEach(([item, ust]) => {
|
||||
if (!s[item]) return;
|
||||
const jaContava = existente && consomem.has(existente.status) ? existente.ust_por_item?.[item] || 0 : 0;
|
||||
const novoSaldo = s[item].saldo + jaContava - (Number(ust) || 0);
|
||||
if (novoSaldo < 0) alerta(`Saldo do pool ${item} ficaria negativo (${novoSaldo} UST) — confira os valores.`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!String(sd.resumo || "").trim()) alerta("Resumo vazio — o redator usa o resumo para citar precedentes.");
|
||||
return avisos;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Repara históricos em que um tool_use ficou sem o tool_result seguinte
|
||||
* (ex.: turno truncado por max_tokens). Sem isso, a API rejeita TODAS as
|
||||
* mensagens seguintes da conversa com erro 400
|
||||
* ("tool_use ids were found without tool_result blocks immediately after").
|
||||
*/
|
||||
export function repararHistorico(mensagens) {
|
||||
const saida = [];
|
||||
for (let i = 0; i < mensagens.length; i++) {
|
||||
const m = mensagens[i];
|
||||
saida.push(m);
|
||||
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
||||
const ids = m.content.filter((b) => b.type === "tool_use").map((b) => b.id);
|
||||
if (!ids.length) continue;
|
||||
const prox = mensagens[i + 1];
|
||||
const respondidos = new Set(
|
||||
prox && prox.role === "user" && Array.isArray(prox.content)
|
||||
? prox.content.filter((b) => b.type === "tool_result").map((b) => b.tool_use_id)
|
||||
: []
|
||||
);
|
||||
const faltantes = ids.filter((id) => !respondidos.has(id));
|
||||
if (!faltantes.length) continue;
|
||||
const resultados = faltantes.map((id) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content:
|
||||
"Esta chamada foi interrompida antes de ser processada (turno truncado). " +
|
||||
"O estado NÃO foi aplicado — se necessário, chame atualizar_rascunho novamente com a SD completa.",
|
||||
is_error: true,
|
||||
}));
|
||||
if (respondidos.size) {
|
||||
prox.content = [...resultados, ...prox.content]; // completa o tool_result parcial existente
|
||||
} else {
|
||||
saida.push({ role: "user", content: resultados }); // insere a resposta que faltava
|
||||
}
|
||||
}
|
||||
return saida;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Adaptador Anthropic — implementa a interface ProviderLLM do app.
|
||||
// Interface: executarTurno({ system, messages, tools, onText }) -> mensagem final da API.
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
export function criarAnthropicProvider(config) {
|
||||
const client = new Anthropic({ apiKey: config.apiKey });
|
||||
|
||||
// Fallback server-side: se um classificador de segurança recusar a requisição,
|
||||
// a própria API re-executa em um modelo alternativo recomendado. Se a conta não
|
||||
// tiver o beta disponível, o adaptador detecta o 400 e passa a chamar sem fallback.
|
||||
let fallbackDisponivel = true;
|
||||
|
||||
async function chamar({ system, messages, tools, onText, model }) {
|
||||
const params = {
|
||||
model: model || config.model,
|
||||
// Atenção: max_tokens cobre raciocínio interno + texto + JSON da ferramenta.
|
||||
// O rascunho completo da SD é grande; 16k estourava em turnos avançados.
|
||||
max_tokens: 32000,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
cache_control: { type: "ephemeral" }, // auto-cache do último bloco (histórico da conversa)
|
||||
};
|
||||
if (config.effort) params.output_config = { effort: config.effort };
|
||||
if (fallbackDisponivel) {
|
||||
params.betas = ["server-side-fallback-2026-07-01"];
|
||||
params.fallbacks = "default";
|
||||
}
|
||||
const stream = client.beta.messages.stream(params);
|
||||
for await (const event of stream) {
|
||||
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
||||
onText?.(event.delta.text);
|
||||
}
|
||||
}
|
||||
return await stream.finalMessage();
|
||||
}
|
||||
|
||||
return {
|
||||
nome: "anthropic",
|
||||
modelo: config.model,
|
||||
|
||||
/**
|
||||
* Chamada única (sem streaming) com saída estruturada forçada — usada pela
|
||||
* extração de SD real. Aceita PDF via bloco document na mensagem.
|
||||
*/
|
||||
async executarExtracao({ system, messages, tools, toolName, model }) {
|
||||
const params = {
|
||||
model: model || config.model,
|
||||
max_tokens: 8000,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: { type: "tool", name: toolName },
|
||||
};
|
||||
let resp;
|
||||
try {
|
||||
resp = await client.messages.create(params);
|
||||
} catch (err) {
|
||||
// tool_choice forçado é incompatível com raciocínio estendido em algumas
|
||||
// configurações — repete deixando o modelo decidir (o prompt já obriga a chamada).
|
||||
if (err?.status === 400 && /tool_choice|thinking/i.test(String(err?.message || err))) {
|
||||
resp = await client.messages.create({ ...params, tool_choice: { type: "auto" } });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const tu = (resp.content || []).find((b) => b.type === "tool_use" && b.name === toolName);
|
||||
if (!tu) {
|
||||
const texto = (resp.content || []).filter((b) => b.type === "text").map((b) => b.text).join(" ");
|
||||
throw new Error("O modelo não retornou a extração estruturada." + (texto ? ` Resposta: ${texto.slice(0, 300)}` : ""));
|
||||
}
|
||||
return { input: tu.input, usage: resp.usage };
|
||||
},
|
||||
|
||||
async executarTurno(args) {
|
||||
try {
|
||||
return await chamar(args);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (
|
||||
fallbackDisponivel &&
|
||||
err?.status === 400 &&
|
||||
/fallback/i.test(msg)
|
||||
) {
|
||||
// Conta sem o beta de fallback: repete sem ele e não tenta de novo.
|
||||
fallbackDisponivel = false;
|
||||
return await chamar(args);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Camada de abstração de provedor de LLM.
|
||||
// MVP: adaptador Anthropic. Fase 1.1: adaptador OpenAI implementando a mesma interface.
|
||||
import { criarAnthropicProvider } from "./anthropic.js";
|
||||
|
||||
export function criarProvider(config) {
|
||||
switch (config.provider) {
|
||||
case "anthropic":
|
||||
return criarAnthropicProvider(config);
|
||||
case "openai":
|
||||
throw new Error(
|
||||
"Adaptador OpenAI ainda não implementado (previsto para a fase 1.1). Use LLM_PROVIDER=anthropic."
|
||||
);
|
||||
default:
|
||||
throw new Error(`Provedor desconhecido: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Monta o system prompt do redator: prompt-redator-sd.md + regras + schema + base,
|
||||
// mais a seção <app> que adapta o comportamento ao aplicativo (tool em vez de bloco de texto).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJETO_DIR } from "./../config.js";
|
||||
|
||||
const SECAO_APP = `
|
||||
<app>
|
||||
Você está rodando dentro do aplicativo Redator de SD. Ajustes ao <modo_de_conducao> e à <saida>:
|
||||
|
||||
1. NÃO escreva o bloco "RASCUNHO ATÉ AQUI" em texto. Em vez disso, ao final de CADA turno em que
|
||||
algum campo da SD mudou, chame a ferramenta atualizar_rascunho com o estado COMPLETO da SD até
|
||||
aqui (campos ainda desconhecidos podem ficar ausentes, mas envie tudo o que já foi definido —
|
||||
a ferramenta substitui o estado anterior por inteiro). É essa chamada que alimenta o painel do
|
||||
documento que o usuário vê ao lado do chat.
|
||||
2. O resultado da ferramenta traz a validação DETERMINÍSTICA do app (os itens "codigo" do checklist,
|
||||
recalculados em código). Trate esse resultado como autoridade: se apontar falha, corrija a SD e
|
||||
chame a ferramenta de novo no mesmo turno antes de responder ao usuário.
|
||||
3. Na <saida> final (Passo 6), entregue apenas o rascunho legível no chat — NÃO cole o JSON em bloco
|
||||
de código; o JSON vai pela ferramenta. Preencha também o array "validacao" da SD com o seu parecer
|
||||
sobre os itens de "julgamento" (ENQ-02..05, DOC-01, DOC-04..06, DOC-10, DOC-11).
|
||||
4. A confirmação de artefatos (DOC-08) acontece na interface, fora do chat: o usuário marca checkbox
|
||||
e data no painel. Não insista em pedir confirmação textual; registre os artefatos como tipos com
|
||||
confirmado_pelo_usuario=false e siga.
|
||||
5. No texto do chat, não use tabelas markdown — use listas. As opções de resposta que você oferece
|
||||
devem vir em linhas iniciadas por "OPCAO:" (ex.: "OPCAO: 1. Ciclo completo → 4 entregáveis"),
|
||||
uma por linha, que o app transforma em botões clicáveis.
|
||||
6. A primeira mensagem do usuário traz um bloco <contexto-do-app> com o número provisório da SD,
|
||||
a data de hoje e o saldo real dos pools — use esses valores; não os recalcule nem os questione.
|
||||
</app>`;
|
||||
|
||||
const ARQUIVOS = [
|
||||
"prompt-redator-sd.md",
|
||||
"regras-sd.json",
|
||||
"schema-sd.json",
|
||||
"base-conhecimento.json",
|
||||
];
|
||||
|
||||
let cache = null;
|
||||
let assinatura = "";
|
||||
|
||||
// Assinatura por data de modificação: se qualquer arquivo-fonte mudou no disco,
|
||||
// o prompt é remontado na próxima conversa — sem precisar reiniciar o servidor.
|
||||
function assinaturaArquivos() {
|
||||
return ARQUIVOS.map((f) => {
|
||||
try {
|
||||
return fs.statSync(path.join(PROJETO_DIR, f)).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}).join("|");
|
||||
}
|
||||
|
||||
export function montarSystem() {
|
||||
const atual = assinaturaArquivos();
|
||||
if (cache && assinatura === atual) return cache;
|
||||
assinatura = atual;
|
||||
cache = null;
|
||||
const prompt = fs.readFileSync(path.join(PROJETO_DIR, "prompt-redator-sd.md"), "utf8");
|
||||
const regras = fs.readFileSync(path.join(PROJETO_DIR, "regras-sd.json"), "utf8");
|
||||
const schema = fs.readFileSync(path.join(PROJETO_DIR, "schema-sd.json"), "utf8");
|
||||
let baseTexto;
|
||||
try {
|
||||
baseTexto = fs.readFileSync(path.join(PROJETO_DIR, "base-conhecimento.json"), "utf8");
|
||||
} catch {
|
||||
baseTexto = '{"sds": [], "capacidades": []}';
|
||||
}
|
||||
|
||||
// Injeta a base no bloco <base_consultada> do prompt (substitui o comentário de injeção).
|
||||
const promptComBase = prompt.replace(
|
||||
/<base_consultada>[\s\S]*?<\/base_consultada>/,
|
||||
`<base_consultada>\nConteúdo de base-conhecimento.json (registros com "EXEMPLO" no id devem ser ignorados):\n${baseTexto}\n</base_consultada>`
|
||||
);
|
||||
|
||||
const texto = [
|
||||
promptComBase,
|
||||
`<regras>\nConteúdo de regras-sd.json:\n${regras}\n</regras>`,
|
||||
`<schema>\nConteúdo de schema-sd.json (formato da SD e da ferramenta atualizar_rascunho):\n${schema}\n</schema>`,
|
||||
SECAO_APP,
|
||||
].join("\n\n");
|
||||
|
||||
// Um único bloco com cache_control: o prefixo inteiro (estável) é cacheado;
|
||||
// o que varia por conversa vai na primeira mensagem do usuário.
|
||||
cache = [{ type: "text", text: texto, cache_control: { type: "ephemeral" } }];
|
||||
return cache;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Ferramenta atualizar_rascunho: o input é a própria SD, no formato de schema-sd.json.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJETO_DIR } from "./../config.js";
|
||||
|
||||
function limparSchema(node) {
|
||||
// Remove chaves de metadado que não pertencem a um input_schema de ferramenta.
|
||||
if (Array.isArray(node)) return node.map(limparSchema);
|
||||
if (node && typeof node === "object") {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (["$schema", "$id", "$comment", "title"].includes(k)) continue;
|
||||
out[k] = limparSchema(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
let cache = null;
|
||||
|
||||
export function ferramentas() {
|
||||
if (cache) return cache;
|
||||
const schema = JSON.parse(
|
||||
fs.readFileSync(path.join(PROJETO_DIR, "schema-sd.json"), "utf8")
|
||||
);
|
||||
const inputSchema = limparSchema(schema);
|
||||
// O rascunho parcial não tem todos os campos obrigatórios ainda — relaxa o required
|
||||
// do nível raiz para a ferramenta aceitar estados intermediários da entrevista.
|
||||
delete inputSchema.required;
|
||||
|
||||
cache = [
|
||||
{
|
||||
name: "atualizar_rascunho",
|
||||
description:
|
||||
"Atualiza o painel do documento com o estado COMPLETO e atual da SD (substitui o estado " +
|
||||
"anterior por inteiro — envie todos os campos já definidos, não apenas o que mudou). " +
|
||||
"Chame ao final de cada turno em que algum campo da SD mudou, e novamente no mesmo turno " +
|
||||
"se o resultado da validação apontar falha que você consiga corrigir. O resultado retorna " +
|
||||
"a validação determinística do app (itens 'codigo' do checklist, UST recalculada, subtotais).",
|
||||
input_schema: inputSchema,
|
||||
},
|
||||
];
|
||||
return cache;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { APP_DIR, config, configOk } from "./config.js";
|
||||
import { criarProvider } from "./llm/provider.js";
|
||||
import { montarSystem } from "./redator/prompt.js";
|
||||
import { ferramentas } from "./redator/tools.js";
|
||||
import {
|
||||
validar,
|
||||
resumoParaRedator,
|
||||
construirExport,
|
||||
bloqueiosDeExportacao,
|
||||
CHECKLIST,
|
||||
REGRAS,
|
||||
} from "./validador.js";
|
||||
import { lerBase, proximoNumero, saldos, contextoParaConversa, registrarEnvio, gravarSdReal } from "./base.js";
|
||||
import { TOOL_EXTRACAO, montarSystemExtracao, montarConteudoDocumento, conferirExtracao } from "./extrator.js";
|
||||
import { repararHistorico } from "./historico.js";
|
||||
import { listarSds, obterSd, criarSd, salvarSd, excluirSd, numerosEmUso } from "./store.js";
|
||||
|
||||
const app = express();
|
||||
// 25mb comporta um PDF de SD em base64 (rota /api/base/extrair); as demais rotas têm corpos pequenos.
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
app.use(express.static(path.join(APP_DIR, "public")));
|
||||
|
||||
let provider = null;
|
||||
function getProvider() {
|
||||
if (!configOk()) throw new Error("ANTHROPIC_API_KEY não configurada. Copie .env.example para .env e preencha a chave.");
|
||||
if (!provider) provider = criarProvider(config);
|
||||
return provider;
|
||||
}
|
||||
|
||||
function somarUso(acc, usage) {
|
||||
if (!usage) return;
|
||||
acc.input_tokens += usage.input_tokens || 0;
|
||||
acc.output_tokens += usage.output_tokens || 0;
|
||||
acc.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
|
||||
acc.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
|
||||
acc.turnos += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa um turno do redator (com loop de ferramenta) e transmite o progresso
|
||||
* ao navegador como NDJSON: {t:"delta"|"rascunho"|"uso"|"erro"|"fim", ...}
|
||||
*/
|
||||
async function rodarTurno(registro, res) {
|
||||
res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
const send = (obj) => res.write(JSON.stringify(obj) + "\n");
|
||||
|
||||
try {
|
||||
const prov = getProvider();
|
||||
const system = montarSystem();
|
||||
const tools = ferramentas();
|
||||
let textoDoTurno = "";
|
||||
let iteracoes = 0;
|
||||
let truncamentos = 0;
|
||||
|
||||
// Cura históricos quebrados por versões anteriores (tool_use sem tool_result).
|
||||
registro.mensagens = repararHistorico(registro.mensagens);
|
||||
|
||||
while (iteracoes++ < 8) {
|
||||
const final = await prov.executarTurno({
|
||||
system,
|
||||
messages: registro.mensagens,
|
||||
tools,
|
||||
model: registro.modelo || undefined,
|
||||
onText: (t) => {
|
||||
textoDoTurno += t;
|
||||
send({ t: "delta", text: t });
|
||||
},
|
||||
});
|
||||
somarUso(registro.uso, final.usage);
|
||||
registro.mensagens.push({ role: "assistant", content: final.content });
|
||||
|
||||
const truncado = final.stop_reason === "max_tokens";
|
||||
const toolUses = (final.content || []).filter((b) => b.type === "tool_use");
|
||||
|
||||
// Regra de ouro da API: TODO tool_use recebe tool_result na mensagem seguinte,
|
||||
// seja qual for o stop_reason — senão o histórico fica inválido para sempre.
|
||||
if (toolUses.length) {
|
||||
const results = [];
|
||||
for (const tu of toolUses) {
|
||||
if (truncado) {
|
||||
results.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: tu.id,
|
||||
content:
|
||||
"A chamada foi truncada pelo limite de tokens e NÃO foi aplicada. " +
|
||||
"Chame atualizar_rascunho novamente com a SD completa.",
|
||||
is_error: true,
|
||||
});
|
||||
} else if (tu.name === "atualizar_rascunho") {
|
||||
registro.rascunho = tu.input;
|
||||
const v = validar(tu.input);
|
||||
send({ t: "rascunho", sd: tu.input, validacao: v, bloqueios: bloqueiosDeExportacao(v) });
|
||||
results.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: tu.id,
|
||||
content: JSON.stringify(resumoParaRedator(v)),
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: tu.id,
|
||||
content: `Ferramenta desconhecida: ${tu.name}`,
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
registro.mensagens.push({ role: "user", content: results });
|
||||
}
|
||||
|
||||
if (final.stop_reason === "refusal") {
|
||||
send({ t: "erro", msg: "A requisição foi recusada pelos classificadores de segurança do modelo. Reformule a mensagem e tente de novo." });
|
||||
break;
|
||||
}
|
||||
if (truncado) {
|
||||
// Dá uma chance de o modelo retomar; se truncar de novo, encerra com aviso.
|
||||
if (++truncamentos <= 1 && toolUses.length) continue;
|
||||
send({ t: "erro", msg: "A resposta excedeu o limite de tokens do turno. Envie 'continue' para retomar." });
|
||||
break;
|
||||
}
|
||||
if (final.stop_reason === "pause_turn") continue;
|
||||
if (final.stop_reason !== "tool_use" || !toolUses.length) break;
|
||||
}
|
||||
|
||||
registro.chat.push({ role: "assistant", texto: textoDoTurno });
|
||||
salvarSd(registro);
|
||||
send({ t: "uso", uso: registro.uso });
|
||||
send({ t: "fim" });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
send({ t: "erro", msg: String(err?.message || err) });
|
||||
salvarSd(registro);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Autenticação (usuário único via .env) ----------
|
||||
|
||||
const sessoes = new Set(); // tokens ativos (memória — reiniciar o servidor exige novo login)
|
||||
const authAtivo = () => Boolean(config.usuario && config.senha);
|
||||
const tokenDe = (req) => /(?:^|;\s*)sd_token=([^;]+)/.exec(req.headers.cookie || "")?.[1] || null;
|
||||
const logado = (req) => !authAtivo() || sessoes.has(tokenDe(req));
|
||||
|
||||
app.get("/api/sessao", (req, res) => {
|
||||
res.json({ precisa_login: authAtivo(), logado: logado(req), configurado: configOk() });
|
||||
});
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
if (!authAtivo()) return res.json({ ok: true });
|
||||
const { usuario, senha } = req.body || {};
|
||||
const okU = typeof usuario === "string" &&
|
||||
usuario.length === config.usuario.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(usuario), Buffer.from(config.usuario));
|
||||
const okS = typeof senha === "string" &&
|
||||
senha.length === config.senha.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(senha), Buffer.from(config.senha));
|
||||
if (okU && okS) {
|
||||
const t = crypto.randomUUID();
|
||||
sessoes.add(t);
|
||||
res.setHeader("Set-Cookie", `sd_token=${t}; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000`);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
setTimeout(() => res.status(401).json({ erro: "Usuário ou senha inválidos." }), 700);
|
||||
});
|
||||
|
||||
app.post("/api/logout", (req, res) => {
|
||||
sessoes.delete(tokenDe(req));
|
||||
res.setHeader("Set-Cookie", "sd_token=; Path=/; Max-Age=0");
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Tudo abaixo desta guarda exige sessão (quando o login estiver configurado).
|
||||
app.use("/api", (req, res, next) => {
|
||||
if (logado(req)) return next();
|
||||
res.status(401).json({ erro: "nao_autenticado" });
|
||||
});
|
||||
|
||||
// ---------- Rotas ----------
|
||||
|
||||
app.get("/api/status", (_req, res) => {
|
||||
const base = lerBase();
|
||||
res.json({
|
||||
configurado: configOk(),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
modelos: config.modelos,
|
||||
proximo_numero: proximoNumero(base, numerosEmUso()),
|
||||
saldos: saldos(base),
|
||||
checklist: CHECKLIST,
|
||||
login_ativo: authAtivo(),
|
||||
regras: {
|
||||
artefatos_minimos: REGRAS.artefatos_minimos,
|
||||
itens: Object.fromEntries(
|
||||
Object.entries(REGRAS.itens).map(([k, v]) => [
|
||||
k,
|
||||
{ numero: v.item_tr_numero, catmas: v.codigo_catmas, titulo_literal: v.titulo_literal },
|
||||
])
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Validação de uma SD externa (JSON colado — não nasceu de uma entrevista do app).
|
||||
app.post("/api/validar", (req, res) => {
|
||||
const sd = req.body?.sd;
|
||||
if (!sd || typeof sd !== "object" || !sd.cabecalho || !Array.isArray(sd.entregaveis))
|
||||
return res.status(400).json({
|
||||
erro: "O JSON não parece uma SD: faltam os campos obrigatórios 'cabecalho' e 'entregaveis' (schema-sd.json).",
|
||||
});
|
||||
const exportado = construirExport(sd);
|
||||
const validacao = validar(exportado);
|
||||
res.json({ exportado, validacao, bloqueios: bloqueiosDeExportacao(validacao) });
|
||||
});
|
||||
|
||||
// ---------- Alimentar a base de conhecimento com uma SD real ----------
|
||||
|
||||
const EXT_ACEITAS = new Set(["pdf", "md", "txt", "markdown"]);
|
||||
|
||||
// Extrai o registro estruturado de um documento de SD (uma chamada de LLM, sem streaming).
|
||||
app.post("/api/base/extrair", async (req, res) => {
|
||||
try {
|
||||
const { nome, base64 } = req.body || {};
|
||||
if (!nome || !base64) return res.status(400).json({ erro: "Envie o arquivo (nome + conteúdo)." });
|
||||
const ext = String(nome).toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || "";
|
||||
if (!EXT_ACEITAS.has(ext))
|
||||
return res.status(400).json({
|
||||
erro: `Formato .${ext || "?"} não suportado. Envie .pdf, .md ou .txt (para .docx, exporte como PDF).`,
|
||||
});
|
||||
const prov = getProvider();
|
||||
const out = await prov.executarExtracao({
|
||||
system: montarSystemExtracao(),
|
||||
messages: [{ role: "user", content: montarConteudoDocumento({ nome, base64 }) }],
|
||||
tools: [TOOL_EXTRACAO],
|
||||
toolName: TOOL_EXTRACAO.name,
|
||||
model: modeloValido(req.body?.modelo) || undefined,
|
||||
});
|
||||
const { sd, capacidades = [], observacoes = [] } = out.input || {};
|
||||
if (sd && !sd.fonte) sd.fonte = nome;
|
||||
res.json({ sd, capacidades, observacoes, conferencia: conferirExtracao(sd), uso: out.usage });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ erro: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Reconfere um registro editado na tela de revisão (só código, sem LLM).
|
||||
app.post("/api/base/conferir", (req, res) => {
|
||||
res.json({ conferencia: conferirExtracao(req.body?.sd) });
|
||||
});
|
||||
|
||||
// Grava o registro revisado. Erros de conferência bloqueiam; alertas passam (o usuário revisou).
|
||||
app.post("/api/base/gravar", (req, res) => {
|
||||
const { sd, capacidades } = req.body || {};
|
||||
const conferencia = conferirExtracao(sd);
|
||||
if (conferencia.some((c) => c.nivel === "erro"))
|
||||
return res.status(409).json({ erro: "Há erros de conferência — corrija o registro antes de gravar.", conferencia });
|
||||
const r = gravarSdReal(sd, Array.isArray(capacidades) ? capacidades : []);
|
||||
res.json({ ok: true, ...r, saldos: saldos(lerBase()) });
|
||||
});
|
||||
|
||||
app.get("/api/sds", (_req, res) => res.json(listarSds()));
|
||||
|
||||
app.get("/api/sds/:id", (req, res) => {
|
||||
const registro = obterSd(req.params.id);
|
||||
if (!registro) return res.status(404).json({ erro: "SD não encontrada" });
|
||||
const validacao = registro.rascunho ? validar(registro.rascunho) : null;
|
||||
res.json({
|
||||
id: registro.id,
|
||||
numero: registro.numero,
|
||||
modelo: registro.modelo || "",
|
||||
status: registro.status,
|
||||
criadaEm: registro.criadaEm,
|
||||
demanda: registro.demanda,
|
||||
chat: registro.chat,
|
||||
rascunho: registro.rascunho,
|
||||
uso: registro.uso,
|
||||
validacao,
|
||||
bloqueios: validacao ? bloqueiosDeExportacao(validacao) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// Modelo vindo da interface: só aceita os da lista configurada (LLM_MODEL + LLM_MODELOS).
|
||||
function modeloValido(m) {
|
||||
return typeof m === "string" && config.modelos.includes(m) ? m : "";
|
||||
}
|
||||
|
||||
// Exclui um rascunho da lista. Uma SD "enviada" pode ser excluída da lista também —
|
||||
// o registro dela na base de conhecimento NÃO é afetado (a base é a memória oficial).
|
||||
app.delete("/api/sds/:id", (req, res) => {
|
||||
if (!excluirSd(req.params.id)) return res.status(404).json({ erro: "SD não encontrada" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Nova SD: cria o registro e roda o primeiro turno (streaming).
|
||||
app.post("/api/sds", async (req, res) => {
|
||||
const demanda = String(req.body?.demanda || "").trim();
|
||||
if (!demanda) return res.status(400).json({ erro: "Informe a demanda bruta." });
|
||||
const numero = proximoNumero(lerBase(), numerosEmUso());
|
||||
const registro = criarSd(demanda, numero, modeloValido(req.body?.modelo));
|
||||
registro.chat = [{ role: "user", texto: demanda }];
|
||||
registro.mensagens.push({
|
||||
role: "user",
|
||||
content: `<contexto-do-app>\n${contextoParaConversa(numero)}\n</contexto-do-app>\n\nDemanda bruta:\n${demanda}`,
|
||||
});
|
||||
res.setHeader("X-Sd-Id", registro.id);
|
||||
await rodarTurno(registro, res);
|
||||
});
|
||||
|
||||
// Turno seguinte da entrevista (streaming).
|
||||
app.post("/api/sds/:id/mensagem", async (req, res) => {
|
||||
const registro = obterSd(req.params.id);
|
||||
if (!registro) return res.status(404).json({ erro: "SD não encontrada" });
|
||||
if (registro.status === "enviada") return res.status(409).json({ erro: "SD já enviada — somente leitura." });
|
||||
const texto = String(req.body?.texto || "").trim();
|
||||
if (!texto) return res.status(400).json({ erro: "Mensagem vazia." });
|
||||
registro.chat.push({ role: "user", texto });
|
||||
registro.mensagens.push({ role: "user", content: texto });
|
||||
await rodarTurno(registro, res);
|
||||
});
|
||||
|
||||
// Trava de integridade: confirmação/data de artefato pelo usuário (fora do chat).
|
||||
app.post("/api/sds/:id/artefato", (req, res) => {
|
||||
const registro = obterSd(req.params.id);
|
||||
if (!registro?.rascunho) return res.status(404).json({ erro: "SD sem rascunho" });
|
||||
const { e, a, confirmado, data, nome } = req.body || {};
|
||||
const art = registro.rascunho.entregaveis?.[e]?.artefatos?.[a];
|
||||
if (!art) return res.status(400).json({ erro: "Artefato não encontrado" });
|
||||
if (typeof confirmado === "boolean") art.confirmado_pelo_usuario = confirmado;
|
||||
if (data !== undefined) {
|
||||
if (data) art.data = data;
|
||||
else delete art.data;
|
||||
}
|
||||
if (typeof nome === "string" && nome.trim()) art.nome = nome.trim();
|
||||
salvarSd(registro);
|
||||
const validacao = validar(registro.rascunho);
|
||||
res.json({ sd: registro.rascunho, validacao, bloqueios: bloqueiosDeExportacao(validacao) });
|
||||
});
|
||||
|
||||
// Exportação do JSON final (bloqueada enquanto houver pendência bloqueante).
|
||||
app.get("/api/sds/:id/export", (req, res) => {
|
||||
const registro = obterSd(req.params.id);
|
||||
if (!registro?.rascunho) return res.status(404).json({ erro: "SD sem rascunho" });
|
||||
const exportado = construirExport(registro.rascunho);
|
||||
const v = validar(exportado);
|
||||
const bloqueios = bloqueiosDeExportacao(v);
|
||||
if (bloqueios.length && req.query.forcar !== "1")
|
||||
return res.status(409).json({ erro: "Exportação bloqueada", bloqueios });
|
||||
const nome = (exportado.cabecalho?.numero_sd || "sd").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${nome}.json"`);
|
||||
res.json(exportado);
|
||||
});
|
||||
|
||||
// Marca como enviada: número definitivo + registro na base de conhecimento.
|
||||
app.post("/api/sds/:id/enviar", (req, res) => {
|
||||
const registro = obterSd(req.params.id);
|
||||
if (!registro?.rascunho) return res.status(404).json({ erro: "SD sem rascunho" });
|
||||
if (registro.status === "enviada") return res.status(409).json({ erro: "SD já enviada." });
|
||||
const exportado = construirExport(registro.rascunho);
|
||||
const v = validar(exportado);
|
||||
const bloqueios = bloqueiosDeExportacao(v);
|
||||
if (bloqueios.length) return res.status(409).json({ erro: "Envio bloqueado", bloqueios });
|
||||
exportado.cabecalho.numero_sd = `SD-${registro.numero}`;
|
||||
registro.rascunho.cabecalho.numero_sd = `SD-${registro.numero}`;
|
||||
const reg = registrarEnvio(exportado);
|
||||
registro.status = "enviada";
|
||||
salvarSd(registro);
|
||||
res.json({ ok: true, registro_na_base: reg });
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`\nRedator de SD rodando em http://localhost:${config.port}`);
|
||||
if (!configOk()) {
|
||||
console.log("⚠ ANTHROPIC_API_KEY não configurada — copie .env.example para .env e preencha a chave.");
|
||||
} else {
|
||||
console.log(`Provedor: ${config.provider} · Modelo: ${config.model}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Persistência simples em arquivo (data/sds.json). SQLite fica para uma fase futura.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "sds.json");
|
||||
|
||||
function lerTudo() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(FILE, "utf8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function gravarTudo(lista) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const tmp = FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(lista, null, 2), "utf8");
|
||||
fs.renameSync(tmp, FILE);
|
||||
}
|
||||
|
||||
export function listarSds() {
|
||||
return lerTudo().map((s) => ({
|
||||
id: s.id,
|
||||
numero: s.numero,
|
||||
titulo: s.rascunho?.cabecalho?.titulo || s.demanda.slice(0, 80),
|
||||
status: s.status,
|
||||
criadaEm: s.criadaEm,
|
||||
turnos: (s.mensagens || []).filter((m) => m.role === "user").length,
|
||||
}));
|
||||
}
|
||||
|
||||
export function obterSd(id) {
|
||||
return lerTudo().find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
export function criarSd(demanda, numero, modelo = "") {
|
||||
const registro = {
|
||||
id: crypto.randomUUID(),
|
||||
numero,
|
||||
modelo, // modelo de LLM fixado na criação ("" = padrão do .env)
|
||||
status: "rascunho", // rascunho -> enviada
|
||||
criadaEm: new Date().toISOString(),
|
||||
demanda,
|
||||
chat: [], // histórico legível para a interface [{role, texto}]
|
||||
mensagens: [], // histórico no formato da API (role/content)
|
||||
rascunho: null, // último estado da SD vindo da tool atualizar_rascunho
|
||||
uso: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, turnos: 0 },
|
||||
};
|
||||
const lista = lerTudo();
|
||||
lista.push(registro);
|
||||
gravarTudo(lista);
|
||||
return registro;
|
||||
}
|
||||
|
||||
export function salvarSd(registro) {
|
||||
const lista = lerTudo();
|
||||
const i = lista.findIndex((s) => s.id === registro.id);
|
||||
if (i === -1) lista.push(registro);
|
||||
else lista[i] = registro;
|
||||
gravarTudo(lista);
|
||||
return registro;
|
||||
}
|
||||
|
||||
export function excluirSd(id) {
|
||||
const lista = lerTudo();
|
||||
const i = lista.findIndex((s) => s.id === id);
|
||||
if (i === -1) return false;
|
||||
lista.splice(i, 1);
|
||||
gravarTudo(lista);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function numerosEmUso() {
|
||||
return lerTudo()
|
||||
.map((s) => Number(s.numero))
|
||||
.filter((n) => !isNaN(n));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Validador determinístico da SD — mesma lógica do painel (artefato) publicado.
|
||||
// Fonte das regras: regras-sd.json na raiz do projeto.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJETO_DIR } from "./config.js";
|
||||
|
||||
export const REGRAS = JSON.parse(
|
||||
fs.readFileSync(path.join(PROJETO_DIR, "regras-sd.json"), "utf8")
|
||||
);
|
||||
|
||||
const TIPO_KEY = {
|
||||
Descoberta: "descoberta",
|
||||
Design: "design",
|
||||
Arquitetura: "arquitetura",
|
||||
"Construção": "construcao",
|
||||
};
|
||||
|
||||
export const CHECKLIST = REGRAS.checklist;
|
||||
|
||||
const dayNum = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000 : NaN;
|
||||
};
|
||||
const fmtD = (iso) => {
|
||||
const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? `${m[3]}/${m[2]}/${m[1]}` : String(iso || "—");
|
||||
};
|
||||
const weeksCal = (i, f) => {
|
||||
const a = dayNum(i), b = dayNum(f);
|
||||
return isNaN(a) || isNaN(b) ? NaN : Math.ceil((b - a + 1) / 7);
|
||||
};
|
||||
|
||||
export function timeboxDe(e) {
|
||||
const it = REGRAS.itens[e.item_tr];
|
||||
const k = TIPO_KEY[e.tipo];
|
||||
return it && k ? it.timebox_h[k] : NaN;
|
||||
}
|
||||
export function ustDe(e) {
|
||||
const t = timeboxDe(e);
|
||||
return isNaN(t) ? NaN : t * (e.semanas || 0);
|
||||
}
|
||||
export function minArtefatos(ust) {
|
||||
const faixa = REGRAS.artefatos_minimos.find(
|
||||
(b) => b.ust_max === null || ust <= b.ust_max
|
||||
);
|
||||
return faixa ? faixa.artefatos : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roda os itens `codigo` e `usuario` do checklist sobre a SD.
|
||||
* Itens `julgamento` são herdados do array validacao produzido pelo redator.
|
||||
* Retorna { resultados: {id: {status, obs}}, totais: {ok, alerta, falha, pendente} }
|
||||
*/
|
||||
export function validar(sd) {
|
||||
const E = sd.entregaveis || [];
|
||||
const cab = sd.cabecalho || {};
|
||||
const byN = {};
|
||||
E.forEach((e) => (byN[e.n] = e));
|
||||
const R = {};
|
||||
const set = (id, status, obs) => (R[id] = { status, obs });
|
||||
|
||||
const semItem = E.filter((e) => !REGRAS.itens[e.item_tr]);
|
||||
set(
|
||||
"ENQ-01",
|
||||
semItem.length ? "falha" : "ok",
|
||||
semItem.length
|
||||
? "Sem item válido: linhas " + semItem.map((e) => e.n).join(", ")
|
||||
: `${E.length} entregáveis, todos com item válido.`
|
||||
);
|
||||
|
||||
set(
|
||||
"ENQ-06",
|
||||
semItem.length ? "alerta" : "ok",
|
||||
"Cada linha do quadro tem um único item_tr (garantido pela estrutura)."
|
||||
);
|
||||
|
||||
const somaPorItem = {};
|
||||
E.forEach((e) => {
|
||||
somaPorItem[e.item_tr] = (somaPorItem[e.item_tr] || 0) + (ustDe(e) || 0);
|
||||
});
|
||||
const itensUsados = Object.keys(somaPorItem);
|
||||
|
||||
if (itensUsados.length <= 1) {
|
||||
set("ENQ-07", "ok", "SD de item único — subtotal dispensado.");
|
||||
} else {
|
||||
const st = sd.subtotais_por_item || [];
|
||||
const faltam = itensUsados.filter((i) => !st.some((s) => s.item_tr === i));
|
||||
const errados = st.filter(
|
||||
(s) => somaPorItem[s.item_tr] !== undefined && s.ust !== somaPorItem[s.item_tr]
|
||||
);
|
||||
if (faltam.length)
|
||||
set("ENQ-07", "falha", "Sem subtotal declarado para: " + faltam.join(", "));
|
||||
else if (errados.length)
|
||||
set(
|
||||
"ENQ-07",
|
||||
"falha",
|
||||
"Subtotal divergente do recálculo em: " + errados.map((s) => s.item_tr).join(", ")
|
||||
);
|
||||
else
|
||||
set(
|
||||
"ENQ-07",
|
||||
"ok",
|
||||
itensUsados.map((i) => `${i}: ${somaPorItem[i]} UST`).join(" · ")
|
||||
);
|
||||
}
|
||||
|
||||
const lim = REGRAS.limites;
|
||||
const foraSem = E.filter(
|
||||
(e) => !(e.semanas >= lim.semanas_min && e.semanas <= lim.semanas_max)
|
||||
);
|
||||
set(
|
||||
"CAL-01",
|
||||
foraSem.length ? "falha" : "ok",
|
||||
foraSem.length
|
||||
? "Fora de 2–4 semanas: linhas " + foraSem.map((e) => `${e.n} (${e.semanas})`).join(", ")
|
||||
: "Semanas: " + E.map((e) => e.semanas).join(", ") + " — todas entre 2 e 4."
|
||||
);
|
||||
|
||||
const contas = [], erros02 = [];
|
||||
E.forEach((e) => {
|
||||
const exp = ustDe(e);
|
||||
contas.push(`${timeboxDe(e)}×${e.semanas}=${exp}`);
|
||||
if (e.ust !== exp) erros02.push(`linha ${e.n}: declarado ${e.ust}, correto ${exp}`);
|
||||
});
|
||||
set(
|
||||
"CAL-02",
|
||||
erros02.length ? "falha" : "ok",
|
||||
erros02.length ? erros02.join("; ") : "Conta refeita: " + contas.join("; ")
|
||||
);
|
||||
|
||||
const err03 = E.filter((e) => e.tipo === "Construção" && e.timebox_h !== timeboxDe(e));
|
||||
set(
|
||||
"CAL-03",
|
||||
err03.length ? "falha" : "ok",
|
||||
err03.length
|
||||
? "Timebox de Construção errado: linhas " +
|
||||
err03.map((e) => `${e.n} (${e.timebox_h}h, correto ${timeboxDe(e)}h)`).join(", ")
|
||||
: "Construção com timebox correto por item."
|
||||
);
|
||||
|
||||
const soma = E.reduce((a, e) => a + (ustDe(e) || 0), 0);
|
||||
set(
|
||||
"CAL-04",
|
||||
cab.total_ust === soma ? "ok" : "falha",
|
||||
`Σ linhas (recalculado) = ${soma} UST; cabeçalho declara ${cab.total_ust ?? "—"}.`
|
||||
);
|
||||
|
||||
const overl = [];
|
||||
E.forEach((e) =>
|
||||
(e.depende_de || []).forEach((dn) => {
|
||||
const d = byN[dn];
|
||||
if (d && e.periodo && d.periodo && !(dayNum(e.periodo.inicio) > dayNum(d.periodo.fim)))
|
||||
overl.push(`linha ${e.n} sobrepõe a linha ${dn}`);
|
||||
})
|
||||
);
|
||||
set(
|
||||
"CAL-05",
|
||||
overl.length ? "falha" : "ok",
|
||||
overl.length ? overl.join("; ") : "Nenhuma sobreposição entre encadeados (depende_de)."
|
||||
);
|
||||
|
||||
const semNota = E.filter(
|
||||
(e) => (e.paralelo_com || []).length && !(e.nota_paralelismo || "").trim()
|
||||
);
|
||||
const temPar = E.some((e) => (e.paralelo_com || []).length);
|
||||
set(
|
||||
"CAL-06",
|
||||
semNota.length ? "falha" : "ok",
|
||||
semNota.length
|
||||
? "Paralelo sem nota: linhas " + semNota.map((e) => e.n).join(", ")
|
||||
: temPar
|
||||
? "Paralelismo declarado em nota."
|
||||
: "Não há paralelismo na SD."
|
||||
);
|
||||
|
||||
const inicios = E.map((e) => e.periodo?.inicio).filter(Boolean).sort();
|
||||
const fins = E.map((e) => e.periodo?.fim).filter(Boolean).sort();
|
||||
const pi = cab.periodo_execucao?.inicio, pf = cab.periodo_execucao?.fim;
|
||||
const wCal = weeksCal(pi, pf);
|
||||
const probs07 = [];
|
||||
if (inicios.length && pi !== inicios[0])
|
||||
probs07.push(`início do cabeçalho (${fmtD(pi)}) ≠ 1º entregável (${fmtD(inicios[0])})`);
|
||||
if (fins.length && pf !== fins[fins.length - 1])
|
||||
probs07.push(`fim do cabeçalho (${fmtD(pf)}) ≠ último entregável (${fmtD(fins[fins.length - 1])})`);
|
||||
if (!isNaN(wCal) && cab.prazo_execucao_semanas !== wCal)
|
||||
probs07.push(`calendário = ${wCal} semanas; cabeçalho declara ${cab.prazo_execucao_semanas}`);
|
||||
set(
|
||||
"CAL-07",
|
||||
probs07.length ? "falha" : "ok",
|
||||
probs07.length
|
||||
? probs07.join("; ")
|
||||
: `${fmtD(pi)} a ${fmtD(pf)} = ${wCal} semanas de calendário — confere.`
|
||||
);
|
||||
|
||||
const declarados = cab.itens_tr || [];
|
||||
const probs02d = [];
|
||||
itensUsados.forEach((i) => {
|
||||
if (!declarados.includes(i)) probs02d.push(`${i} usado no quadro mas ausente do cabeçalho`);
|
||||
});
|
||||
declarados.forEach((i) => {
|
||||
if (!itensUsados.includes(i)) probs02d.push(`${i} declarado no cabeçalho sem entregável`);
|
||||
});
|
||||
(sd.enquadramento_tr || []).forEach((q) => {
|
||||
if (!itensUsados.includes(q.item_tr))
|
||||
probs02d.push(`Seção 5 traz ${q.item_tr} sem entregável correspondente`);
|
||||
});
|
||||
set(
|
||||
"DOC-02",
|
||||
probs02d.length ? "falha" : "ok",
|
||||
probs02d.length ? probs02d.join("; ") : "Itens do cabeçalho, quadro e Seção 5 correspondem."
|
||||
);
|
||||
|
||||
const err03t = (sd.enquadramento_tr || []).filter(
|
||||
(q) => REGRAS.itens[q.item_tr] && q.titulo_literal !== REGRAS.itens[q.item_tr].titulo_literal
|
||||
);
|
||||
set(
|
||||
"DOC-03",
|
||||
err03t.length ? "falha" : "ok",
|
||||
err03t.length
|
||||
? "Título divergente do TR em: " + err03t.map((q) => q.item_tr).join(", ")
|
||||
: "Títulos literais conferem com regras-sd.json."
|
||||
);
|
||||
|
||||
const err07 = E.filter((e) => (e.artefatos || []).length < minArtefatos(ustDe(e) || 0));
|
||||
set(
|
||||
"DOC-07",
|
||||
err07.length ? "falha" : "ok",
|
||||
err07.length
|
||||
? "Abaixo do mínimo: linhas " +
|
||||
err07.map((e) => `${e.n} (${(e.artefatos || []).length}/${minArtefatos(ustDe(e) || 0)})`).join(", ")
|
||||
: E.map((e) => `E${e.n}: ${(e.artefatos || []).length}/${minArtefatos(ustDe(e) || 0)}`).join(" · ")
|
||||
);
|
||||
|
||||
const todos = E.flatMap((e) => e.artefatos || []);
|
||||
const naoConf = todos.filter((a) => !a.confirmado_pelo_usuario).length;
|
||||
set(
|
||||
"DOC-08",
|
||||
naoConf ? "pendente" : todos.length ? "ok" : "pendente",
|
||||
naoConf
|
||||
? `${naoConf} de ${todos.length} artefatos ainda não confirmados — bloqueante para exportação.`
|
||||
: todos.length
|
||||
? `Todos os ${todos.length} artefatos confirmados pelo usuário.`
|
||||
: "SD ainda sem artefatos."
|
||||
);
|
||||
|
||||
const errData = [], semData = [];
|
||||
E.forEach((e) =>
|
||||
(e.artefatos || []).forEach((a) => {
|
||||
if (a.data) {
|
||||
if (e.periodo?.fim && dayNum(a.data) > dayNum(e.periodo.fim))
|
||||
errData.push(`E${e.n}: "${a.nome}" datado ${fmtD(a.data)} > fim ${fmtD(e.periodo.fim)}`);
|
||||
} else if (a.confirmado_pelo_usuario) {
|
||||
semData.push(`E${e.n}: "${a.nome}" confirmado sem data`);
|
||||
}
|
||||
})
|
||||
);
|
||||
if (errData.length) set("DOC-09", "falha", errData.join("; "));
|
||||
else if (semData.length) set("DOC-09", "alerta", semData.join("; "));
|
||||
else if (todos.some((a) => a.data))
|
||||
set("DOC-09", "ok", "Datas informadas são ≤ ao fim do respectivo entregável.");
|
||||
else set("DOC-09", "alerta", "Nenhum artefato tem data informada ainda.");
|
||||
|
||||
// Itens de julgamento: herdados do parecer do redator (array validacao da SD)
|
||||
const orig = {};
|
||||
(sd.validacao || []).forEach((v) => (orig[v.id] = v));
|
||||
CHECKLIST.filter((c) => c.verificador === "julgamento").forEach((c) => {
|
||||
const o = orig[c.id];
|
||||
set(
|
||||
c.id,
|
||||
o?.status || "pendente",
|
||||
o?.observacao || (o ? "Revisão do redator: sem ressalvas." : "Sem parecer do redator ainda.")
|
||||
);
|
||||
});
|
||||
|
||||
const totais = { ok: 0, alerta: 0, falha: 0, pendente: 0 };
|
||||
Object.values(R).forEach((r) => (totais[r.status] = (totais[r.status] || 0) + 1));
|
||||
return { resultados: R, totais, soma_ust: soma, soma_por_item: somaPorItem };
|
||||
}
|
||||
|
||||
/** Resumo compacto (só o que não está ok) para realimentar o redator via tool_result. */
|
||||
export function resumoParaRedator(validacao) {
|
||||
const pend = Object.entries(validacao.resultados)
|
||||
.filter(([, r]) => r.status !== "ok")
|
||||
.map(([id, r]) => `${id} [${r.status}]: ${r.obs}`);
|
||||
return {
|
||||
validador_do_app: {
|
||||
total_ust_recalculado: validacao.soma_ust,
|
||||
subtotais: validacao.soma_por_item,
|
||||
itens_nao_ok: pend.length ? pend : "todos os checks de código passaram",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconstrói a SD com valores recalculados pelo app + array validacao completo (para exportação). */
|
||||
export function construirExport(sd) {
|
||||
const copy = JSON.parse(JSON.stringify(sd));
|
||||
const E = copy.entregaveis || [];
|
||||
E.forEach((e) => {
|
||||
e.timebox_h = timeboxDe(e);
|
||||
e.ust = ustDe(e);
|
||||
});
|
||||
const soma = E.reduce((a, e) => a + (e.ust || 0), 0);
|
||||
copy.cabecalho = copy.cabecalho || {};
|
||||
copy.cabecalho.total_ust = soma;
|
||||
const porItem = {};
|
||||
E.forEach((e) => (porItem[e.item_tr] = (porItem[e.item_tr] || 0) + (e.ust || 0)));
|
||||
if (Object.keys(porItem).length > 1)
|
||||
copy.subtotais_por_item = Object.keys(porItem)
|
||||
.sort()
|
||||
.map((i) => ({ item_tr: i, ust: porItem[i] }));
|
||||
const v = validar(copy);
|
||||
copy.validacao = CHECKLIST.map((c) => {
|
||||
const r = v.resultados[c.id];
|
||||
const o = { id: c.id, status: r?.status || "pendente" };
|
||||
if (r?.obs && o.status !== "ok") o.observacao = r.obs;
|
||||
return o;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Exportação é bloqueada enquanto houver artefato não confirmado ou falha de código. */
|
||||
export function bloqueiosDeExportacao(validacao) {
|
||||
const razoes = [];
|
||||
if (validacao.resultados["DOC-08"]?.status !== "ok")
|
||||
razoes.push("artefatos não confirmados (DOC-08)");
|
||||
const falhas = Object.entries(validacao.resultados)
|
||||
.filter(([, r]) => r.status === "falha")
|
||||
.map(([id]) => id);
|
||||
if (falhas.length) razoes.push("falhas no checklist: " + falhas.join(", "));
|
||||
return razoes;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$comment": "Base de conhecimento do redator de SDs — RMDS / SES-MG (PE 29/2025). Terceiro arquivo da arquitetura: regras-sd.json = regras; schema-sd.json = formato; ESTE arquivo = histórico. Cadastre apenas fatos reais — o redator é proibido de inventar precedentes, e esta base só serve se for verdadeira. Uma linha nova em 'sds' a cada SD enviada (o app faz isso automaticamente); uma em 'capacidades' a cada capacidade entregue (manual).",
|
||||
"versao": "0.2.0",
|
||||
"atualizado_em": "2026-08-11",
|
||||
|
||||
"convencoes": {
|
||||
"status_sd": ["rascunho", "enviada", "aprovada", "em_execucao", "concluida", "faturada", "cancelada"],
|
||||
"status_que_consomem_pool": ["enviada", "aprovada", "em_execucao", "concluida", "faturada"],
|
||||
"status_capacidade": ["em_construcao", "entregue"],
|
||||
"saldo_de_pool": "saldo(item) = pool_ust_total do item em regras-sd.json − soma de ust_por_item das SDs cujo status consome pool",
|
||||
"tags": "minúsculas, sem acento, singular quando possível; ex.: dashboard, integracao, niten, regulacao, vacinacao, treinamento, automacao",
|
||||
"ids": "SD-NN segue a numeração oficial do contrato; CAP-NN é sequencial interno desta base"
|
||||
},
|
||||
|
||||
"sds": [
|
||||
{
|
||||
"id": "SD-8",
|
||||
"titulo": "MVP Vacina em Dia — caderneta vacinal digital no MG App com dados do PNI e alertas de pendências",
|
||||
"projeto": "P2 — Vacinação (Vacina em Dia)",
|
||||
"itens_tr": ["I-02", "I-05"],
|
||||
"ust_total": 1120,
|
||||
"ust_por_item": { "I-02": 720, "I-05": 400 },
|
||||
"status": "concluida",
|
||||
"periodo": { "inicio": "2026-06-02", "fim": "2026-07-04" },
|
||||
"resumo": "Caderneta vacinal digital do cidadão no MG App: histórico de imunização unificado via PNI/RNDS com identificação por CNS/CPF, motor determinístico com 60+ regras vacinais versionadas, 5 status de pendência, perfis por faixa etária/gestante/profissional de saúde e filtro de segurança clínica. Publicado em produção em 02/07/2026, antes da vedação eleitoral. Ciclo completo (Descoberta, Design, Arquitetura e Construção) em 10 entregáveis, com paralelismo intenso: 24 semanas de timebox em ~5 semanas de calendário.",
|
||||
"tags": ["vacinacao", "caderneta-digital", "mg-app", "pni", "rnds", "motor-de-regras", "integracao", "lgpd"],
|
||||
"fonte": "P2-SD8_MVP_VacinaEmDia.md"
|
||||
}
|
||||
],
|
||||
|
||||
"capacidades": [
|
||||
{
|
||||
"id": "CAP-01",
|
||||
"titulo": "Integração com a base nacional PNI/RNDS para consulta de histórico vacinal",
|
||||
"descricao": "A plataforma já consome o histórico vacinal do cidadão em tempo real via middleware Azure SES-MG (login → token → search) sobre o bucket ElasticSearch do PNI, com contrato de dados e schema documentados e tratamento de códigos corrompidos. Nova SD que precise de dados de vacinação NÃO precisa reconstruir esta integração.",
|
||||
"origem_sd": "SD-8",
|
||||
"item_tr": "I-02",
|
||||
"status": "entregue",
|
||||
"tags": ["integracao", "pni", "rnds", "vacinacao"]
|
||||
},
|
||||
{
|
||||
"id": "CAP-02",
|
||||
"titulo": "Embedamento de aplicações da plataforma no MG App",
|
||||
"descricao": "Caminho técnico completo para publicar módulos dentro do MG App (super-app do Estado): autenticação com token JWT, cadastro no back-office do MG App junto à Prodemge, publicação de URL via DNS ATI SES e padrão mobile-first com telas de fallback. Nova SD com entrega ao cidadão via MG App reaproveita este caminho.",
|
||||
"origem_sd": "SD-8",
|
||||
"item_tr": "I-02",
|
||||
"status": "entregue",
|
||||
"tags": ["mg-app", "integracao", "autenticacao", "prodemge"]
|
||||
},
|
||||
{
|
||||
"id": "CAP-03",
|
||||
"titulo": "Motor determinístico de regras vacinais",
|
||||
"descricao": "Motor em memória com 60+ regras vacinais em arquivos versionados e auditáveis, hierarquia de resolução de conflitos entre perfis (faixa etária, gestante, profissional de saúde), regras de segurança clínica (ocultação de vacinas vivas para gestantes) e endpoint próprio, validado clinicamente com a SVE. Evoluções de calendário/regra são manutenção deste motor (I-05), não construção nova.",
|
||||
"origem_sd": "SD-8",
|
||||
"item_tr": "I-05",
|
||||
"status": "entregue",
|
||||
"tags": ["motor-de-regras", "vacinacao", "algoritmo"]
|
||||
},
|
||||
{
|
||||
"id": "CAP-04",
|
||||
"titulo": "De-para semântico de siglas PNI/MG para vocabulário canônico de vacinas",
|
||||
"descricao": "Mapeamento de 103 siglas do índice PNI/MG para o vocabulário canônico de vacinas (3 mappers implementados), com tratamento de siglas corrompidas. Qualquer SD que consuma dados vacinais do PNI reaproveita este dicionário.",
|
||||
"origem_sd": "SD-8",
|
||||
"item_tr": "I-05",
|
||||
"status": "entregue",
|
||||
"tags": ["pni", "vacinacao", "dado", "interoperabilidade"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
git init -b main
|
||||
git add .
|
||||
git commit -m "Redator de SD - preparado para deploy"
|
||||
git remote add origin https://gitea.aplicativopro.com/wander/RedatorSD.git
|
||||
git push -u origin main
|
||||
@@ -0,0 +1,102 @@
|
||||
# Prompt do Redator de Demandas (SD) — RMDS / SES-MG
|
||||
|
||||
> **Como usar no Cowork ou em um Projeto do Claude:** cole o conteúdo abaixo (do primeiro `<papel>` ao fim) nas instruções, e anexe `regras-sd.json` e `schema-sd.json` como arquivos do projeto. Depois, abra a conversa colando a demanda bruta.
|
||||
> **Como usar no app:** este mesmo texto vira o `system` da chamada à API; os dois JSONs são injetados nos blocos indicados.
|
||||
|
||||
---
|
||||
|
||||
<papel>
|
||||
Você é o Redator de Demandas do contrato Pregão Eletrônico nº 29/2025 (SES-MG × Isis Saúde), programa RMDS. Sua função: a partir de uma demanda bruta (trecho de ata, e-mail, mensagem), conduzir uma entrevista curta e devolver o rascunho completo de uma Solicitação de Demanda (SD) — enquadrada, dimensionada e validada.
|
||||
|
||||
Você conduz; o usuário decide. Você propõe enquadramento, entregáveis e semanas com justificativa; o usuário confirma ou corrige. Números e regras duras vêm de <regras>; você nunca os inventa nem os contraria.
|
||||
</papel>
|
||||
|
||||
<referencias>
|
||||
O conteúdo de `regras-sd.json` é a fonte única das regras determinísticas: itens do TR e títulos literais, timeboxes, limites de semanas, fórmula de UST, mínimos de artefatos, regras de data e o checklist com IDs.
|
||||
O conteúdo de `schema-sd.json` define o formato exato da SD que você entrega ao final.
|
||||
Se qualquer instrução sua conflitar com esses arquivos, os arquivos vencem.
|
||||
</referencias>
|
||||
|
||||
<modo_de_conducao>
|
||||
Regras invioláveis da entrevista:
|
||||
|
||||
1. **Uma pergunta por turno.** Nunca duas. Nunca um questionário.
|
||||
2. **Sempre ofereça 2–3 opções prontas** de resposta (com uma linha de consequência em cada: "→ muda o item", "→ encurta a descoberta"), deixando claro que o usuário pode responder livremente.
|
||||
3. **Nunca pergunte o que dá para inferir.** Antes da primeira pergunta, devolva: (a) o que você entendeu da demanda em 2–3 frases; (b) o que consultou na base, citando IDs; (c) só então a primeira lacuna real.
|
||||
4. **Ordem fixa das lacunas** (pule as que já estiverem respondidas na demanda bruta ou na base):
|
||||
- Passo 1 · Natureza — o que o cliente quer VER funcionando; já existe ou é novo; uma ou mais naturezas de trabalho.
|
||||
- Passo 2 · Enquadramento — item(ns) do TR, aplicando <enquadramento>. Se cruzar itens, avise que viram entregáveis separados com subtotal por item.
|
||||
- Passo 3 · Frentes — quantos entregáveis e de quais tipos, aplicando as regras de quebra de <regras>. Nem toda demanda tem os 4 tipos.
|
||||
- Passo 4 · Dimensionamento — semanas por entregável (justifique pela complexidade), datas, encadeamento vs. paralelismo. UST você calcula pela fórmula e mostra a conta.
|
||||
- Passo 5 · Lastro — que artefatos comprobatórios EXISTEM ou existirão. Aplique <integridade>.
|
||||
- Passo 6 · Geração — rode <validacao>, apresente o rascunho e as pendências.
|
||||
5. **Estado visível a cada turno.** Encerre toda resposta com um bloco `RASCUNHO ATÉ AQUI` mostrando os campos já preenchidos (título provisório, item, quadro parcial de entregáveis com UST calculada). No app, esse bloco alimenta o painel do documento.
|
||||
6. **Alertas no momento certo.** Prazo do demandante menor que o desenho, dependência externa, escopo que estoura 4 semanas: sinalize assim que detectar, com a alternativa concreta (ex.: "ou repactua o prazo, ou a descoberta cai para 2 semanas com escopo reduzido a X").
|
||||
</modo_de_conducao>
|
||||
|
||||
<enquadramento>
|
||||
As 4 perguntas, nesta ordem:
|
||||
1. É plataforma crescendo (módulo novo, fonte nova, conector novo)? → I-02
|
||||
2. É ensinar alguém a usar algo já implantado? → I-03
|
||||
3. É automatizar tarefa repetitiva ou corrigir algo em produção? → I-04
|
||||
4. É criar dashboard, algoritmo, IA, busca ativa, análise? → I-05
|
||||
|
||||
Teste da estrada (dúvida I-02 × I-04): I-02 constrói a estrada; I-04 põe o piloto automático. Primeira vez que conecta ou constrói → I-02. Automatizar o que já poderia ser feito manualmente → I-04.
|
||||
|
||||
Exemplos resolvidos (use como precedente, não repita ao usuário sem necessidade):
|
||||
- Produto novo sendo construído → I-02 (ainda não existe para ser mantido)
|
||||
- Dashboard, índice, score, ranking → I-05 (é inteligência, não automação)
|
||||
- App em produção com bug de interface → I-04 (manutenção perfectiva)
|
||||
- Regra de negócio nova no motor → I-05 (evolução de algoritmo)
|
||||
- Oficina de discovery pré-implantação → I-05 (I-03 é consultoria PÓS-implantação)
|
||||
|
||||
Demanda com mais de uma natureza cruza mais de um item: normal, vira entregáveis separados que nunca se somam na mesma linha.
|
||||
</enquadramento>
|
||||
|
||||
<dimensionamento>
|
||||
- Semanas por entregável: escolha 2, 3 ou 4 usando a tabela de <regras> e SEMPRE justifique em uma frase concreta ("definir fila com a SUBRAS envolve área finalística e casos de borda — não fecha em duas semanas").
|
||||
- UST: aplique a fórmula de <regras> e mostre a conta (ex.: "Construção I-05, 3 semanas → 80h × 3 = 240 UST"). Nunca estime de cabeça.
|
||||
- Datas: encadeados não se sobrepõem; paralelos exigem times distintos + nota de paralelismo; data final de cada entregável precisa ser compatível com a data dos artefatos.
|
||||
- Não crie tipo de entregável para "ficar completo", nem quebre entregável só para ter mais linhas.
|
||||
</dimensionamento>
|
||||
|
||||
<redacao>
|
||||
Ao gerar o rascunho final, siga o schema e estas regras de texto:
|
||||
|
||||
- **Objetivo:** 2–4 períodos; o que a SD formaliza e o resultado que permite alcançar; não repete contexto.
|
||||
- **Contexto:** 3–5 parágrafos na ordem: problema de gestão → insumo disponível e por que não basta → o que a demanda entrega → achado/resultado (se houver) → desdobramento. Só números reais informados pelo usuário ou presentes na base; nunca invente números.
|
||||
- **Nomes:** o produto tem UM nome, usado identicamente do título ao último critério.
|
||||
- **Critérios de aceite:** verificáveis por terceiro olhando o artefato.
|
||||
- Servem: "Índice reproduzível: mesma base e mesmo código produzem resultado idêntico"; "Dashboard acessível pelos perfis indicados pela SES-MG"; "Toda pergunta do formulário mapeada à dimensão que alimenta".
|
||||
- Não servem: "Modelo bem construído"; "Dashboard funcionando adequadamente"; "Documentação completa".
|
||||
- **Aderência (Seção 5):** 3–5 bullets, um por componente ENTREGUE, no vocabulário do TR (use `descricao_tr` de <regras>), sem citar componente que a SD não tem. Repita a seção inteira por item quando a SD cruza itens, indicando quais entregáveis pertencem a cada um.
|
||||
</redacao>
|
||||
|
||||
<integridade>
|
||||
- Você sugere TIPOS de artefato comprobatório ("ata de reunião de validação", "nota técnica", "print do painel publicado com data"). Nomes concretos de documentos só entram na SD quando o usuário os informar ou confirmar — no schema, `confirmado_pelo_usuario` fica `false` até isso acontecer, e artefato não confirmado é pendência bloqueante listada em `pendencias`.
|
||||
- Nunca invente números, datas de documentos ou resultados. Campo sem informação fica marcado como pendência, nunca preenchido com plausibilidade.
|
||||
- Se o usuário pedir algo que contraria as regras de <regras> (ex.: entregável de 5 semanas, UST fora da fórmula, artefato com data posterior à data final), não execute silenciosamente: explique a regra, mostre a consequência (glosa, achado de auditoria, reclassificação que derruba o timebox) e ofereça a alternativa regular.
|
||||
</integridade>
|
||||
|
||||
<validacao>
|
||||
Antes de entregar o rascunho, percorra o checklist de <regras> item a item, pelo ID:
|
||||
- Itens `verificador: codigo` — refaça a verificação explicitamente (mostre a conta quando for cálculo). No app, estes serão recalculados em JS; na conversa, você é o executor provisório.
|
||||
- Itens `verificador: julgamento` — avalie como revisor, não como autor: releia o texto gerado procurando a falha, não confirmando o acerto.
|
||||
- Itens `verificador: usuario` — status `pendente` até confirmação explícita.
|
||||
Reporte o resultado como lista ✓ / ! / ✗ com o ID e, para tudo que não for ✓, a observação e a ação sugerida.
|
||||
</validacao>
|
||||
|
||||
<saida>
|
||||
A entrega final tem duas partes, nesta ordem:
|
||||
1. **O rascunho legível da SD**, na estrutura: Cabeçalho → 1. Objetivo → 2. Contexto e escopo → 3. Quadro de entregáveis (com linha de total e subtotais por item quando cruzar itens) → 4. Detalhamento por entregável (Resumo / Atividades / Artefatos / Critérios de aceite) → 5. Enquadramento no TR → Resultado da validação → Pendências.
|
||||
2. **O mesmo conteúdo como JSON válido conforme `schema-sd.json`**, em um único bloco de código, sem comentários. É este JSON que o app consumirá.
|
||||
</saida>
|
||||
|
||||
<abertura>
|
||||
Mensagem inicial da conversa (adapte o tom, mantenha o conteúdo):
|
||||
"Me conta a demanda do jeito que ela chegou — trecho de ata, e-mail encaminhado, mensagem. Não precisa organizar. Eu leio, comparo com o que já foi entregue e vou perguntando só o que faltar para fechar a SD."
|
||||
</abertura>
|
||||
|
||||
<base_consultada>
|
||||
<!-- INJETAR AQUI: entregas anteriores e capacidades da plataforma (no app: retrieval; no Cowork: anexar um base-conhecimento.json com os registros SD-XX e CAP-XX, cada um com id, título, item, ust, status e tags). Ao citar a base, use sempre o ID do registro. Se a base não estiver disponível, diga isso e conduza a entrevista sem ela — nunca invente precedentes. -->
|
||||
</base_consultada>
|
||||
@@ -0,0 +1,719 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Redator de Demandas — protótipo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--ink:#0f2338;
|
||||
--ink-2:#3c5470;
|
||||
--ink-3:#7a8ca1;
|
||||
--canvas:#e7eaef;
|
||||
--panel:#ffffff;
|
||||
--paper:#fdfdfa;
|
||||
--line:#d6dbe3;
|
||||
--line-2:#eceff4;
|
||||
--teal:#0b8fa8;
|
||||
--blue:#1f6fd0;
|
||||
--amber:#a8701a;
|
||||
--amber-bg:#fdf4e3;
|
||||
--rose:#9e2f42;
|
||||
--rose-bg:#fbeef0;
|
||||
--green:#1f7a52;
|
||||
--green-bg:#eaf4ee;
|
||||
--r:3px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
background:var(--canvas);
|
||||
color:var(--ink);
|
||||
font-family:'Inter Tight',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
font-size:14px;
|
||||
line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
overflow:hidden;
|
||||
}
|
||||
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
||||
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
|
||||
/* ---------- shell ---------- */
|
||||
.app{display:grid;grid-template-rows:auto 1fr;height:100vh}
|
||||
|
||||
.topbar{
|
||||
display:flex;align-items:center;gap:14px;
|
||||
padding:0 20px;height:52px;
|
||||
background:var(--ink);color:#fff;
|
||||
border-bottom:1px solid #06172a;
|
||||
}
|
||||
.logo{display:flex;align-items:center;gap:9px;font-weight:600;letter-spacing:-.01em}
|
||||
.logo svg{flex:none}
|
||||
.topbar .sep{width:1px;height:20px;background:rgba(255,255,255,.18)}
|
||||
.topbar .ctx{font-size:12.5px;color:#9fb4ca}
|
||||
.topbar .ctx b{color:#dce8f3;font-weight:500}
|
||||
.topbar .right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
.pill{
|
||||
font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
||||
padding:4px 9px;border-radius:2px;background:rgba(255,255,255,.1);color:#bcd3e8;
|
||||
}
|
||||
.btn-reset{font-size:12.5px;color:#9fb4ca;padding:5px 10px;border:1px solid rgba(255,255,255,.2);border-radius:var(--r)}
|
||||
.btn-reset:hover{color:#fff;border-color:rgba(255,255,255,.45)}
|
||||
|
||||
.main{display:grid;grid-template-columns:1fr 1fr;overflow:hidden}
|
||||
|
||||
/* ---------- coluna esquerda: conversa ---------- */
|
||||
.left{display:flex;flex-direction:column;background:var(--canvas);border-right:1px solid var(--line);overflow:hidden}
|
||||
.left-head{
|
||||
padding:13px 26px;border-bottom:1px solid var(--line);
|
||||
display:flex;align-items:baseline;gap:10px;background:var(--canvas);
|
||||
}
|
||||
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3)}
|
||||
.left-head .step{margin-left:auto;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-3)}
|
||||
|
||||
.stream{flex:1;overflow-y:auto;padding:26px 26px 8px;scroll-behavior:smooth}
|
||||
.stream::-webkit-scrollbar{width:9px}
|
||||
.stream::-webkit-scrollbar-thumb{background:#c3cad4;border-radius:9px;border:3px solid var(--canvas)}
|
||||
|
||||
.msg{margin-bottom:22px;animation:rise .34s cubic-bezier(.2,.7,.3,1) both}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}
|
||||
|
||||
.who{
|
||||
font-size:10.5px;font-weight:700;letter-spacing:.11em;text-transform:uppercase;
|
||||
color:var(--ink-3);margin-bottom:7px;display:flex;align-items:center;gap:7px;
|
||||
}
|
||||
.who i{width:5px;height:5px;border-radius:50%;background:var(--teal);font-style:normal}
|
||||
.msg.user .who i{background:var(--ink-3)}
|
||||
|
||||
.bubble{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:15px 17px;font-size:14px;line-height:1.62}
|
||||
.msg.user .bubble{background:transparent;border-style:dashed;border-color:#c3cad4;color:var(--ink-2)}
|
||||
.bubble p+p{margin-top:10px}
|
||||
.bubble strong{font-weight:600}
|
||||
.bubble .quiet{color:var(--ink-2)}
|
||||
|
||||
/* fonte da base de conhecimento */
|
||||
.sources{margin-top:13px;padding-top:12px;border-top:1px solid var(--line-2)}
|
||||
.sources-h{font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:9px;display:flex;align-items:center;gap:6px}
|
||||
.src{
|
||||
display:flex;gap:10px;padding:8px 10px;border-radius:2px;background:#f4f6f9;
|
||||
border-left:2px solid var(--teal);margin-bottom:6px;
|
||||
}
|
||||
.src:last-child{margin-bottom:0}
|
||||
.src .id{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:var(--teal);flex:none;padding-top:1px}
|
||||
.src .txt{font-size:12.5px;line-height:1.45;color:var(--ink-2)}
|
||||
.src .txt b{color:var(--ink);font-weight:600}
|
||||
|
||||
/* alerta */
|
||||
.flag{margin-top:13px;padding:11px 13px;background:var(--amber-bg);border-left:2px solid var(--amber);font-size:12.8px;line-height:1.5;color:#6b4a12}
|
||||
.flag b{color:var(--amber)}
|
||||
|
||||
/* opções */
|
||||
.opts{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px}
|
||||
.opt{
|
||||
border:1px solid var(--line);background:var(--panel);border-radius:var(--r);
|
||||
padding:9px 14px;font-size:13.2px;font-weight:500;color:var(--ink);
|
||||
transition:.16s;text-align:left;
|
||||
}
|
||||
.opt:hover{border-color:var(--blue);background:#f5f9ff;transform:translateY(-1px)}
|
||||
.opt small{display:block;font-size:11.5px;font-weight:400;color:var(--ink-3);margin-top:2px}
|
||||
.opt.wide{width:100%}
|
||||
|
||||
.typing{display:flex;gap:4px;padding:6px 0}
|
||||
.typing span{width:5px;height:5px;border-radius:50%;background:var(--ink-3);animation:blip 1.1s infinite}
|
||||
.typing span:nth-child(2){animation-delay:.16s}
|
||||
.typing span:nth-child(3){animation-delay:.32s}
|
||||
@keyframes blip{0%,60%,100%{opacity:.25}30%{opacity:1}}
|
||||
|
||||
/* composer */
|
||||
.composer{border-top:1px solid var(--line);padding:14px 26px 18px;background:var(--canvas)}
|
||||
.composer textarea{
|
||||
width:100%;min-height:88px;resize:none;padding:13px 15px;font-family:inherit;font-size:13.5px;line-height:1.55;
|
||||
border:1px solid var(--line);border-radius:var(--r);background:var(--panel);color:var(--ink);
|
||||
}
|
||||
.composer textarea::placeholder{color:#9aa8b8}
|
||||
.composer textarea:focus{border-color:var(--blue);outline:none}
|
||||
.composer-row{display:flex;align-items:center;gap:10px;margin-top:10px}
|
||||
.btn{
|
||||
background:var(--ink);color:#fff;padding:9px 18px;border-radius:var(--r);
|
||||
font-size:13.2px;font-weight:600;transition:.16s;
|
||||
}
|
||||
.btn:hover{background:#1c3d5e}
|
||||
.btn:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn.ghost{background:none;color:var(--ink-2);border:1px solid var(--line)}
|
||||
.btn.ghost:hover{background:var(--panel);color:var(--ink)}
|
||||
.hint{font-size:11.8px;color:var(--ink-3);margin-left:auto}
|
||||
|
||||
/* ---------- coluna direita: documento ---------- */
|
||||
.right{display:flex;flex-direction:column;background:var(--panel);overflow:hidden}
|
||||
.tabs{display:flex;border-bottom:1px solid var(--line);padding:0 26px;background:var(--panel);flex:none}
|
||||
.tab{
|
||||
padding:15px 0;margin-right:26px;font-size:12.5px;font-weight:600;color:var(--ink-3);
|
||||
border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.01em;
|
||||
}
|
||||
.tab.on{color:var(--ink);border-bottom-color:var(--ink)}
|
||||
.tab .n{font-family:'JetBrains Mono',monospace;font-size:10.5px;background:var(--line-2);color:var(--ink-2);padding:1px 5px;border-radius:2px;margin-left:6px}
|
||||
|
||||
.doc-wrap{flex:1;overflow-y:auto;background:#f1f3f6;padding:22px}
|
||||
.doc-wrap::-webkit-scrollbar{width:9px}
|
||||
.doc-wrap::-webkit-scrollbar-thumb{background:#c3cad4;border-radius:9px;border:3px solid #f1f3f6}
|
||||
|
||||
.doc{
|
||||
background:var(--paper);border:1px solid var(--line);
|
||||
padding:34px 38px 40px;max-width:660px;margin:0 auto;
|
||||
font-family:'Newsreader',Georgia,serif;
|
||||
}
|
||||
.doc-head{border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:20px}
|
||||
.doc-kicker{font-family:'Inter Tight',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--teal);margin-bottom:7px}
|
||||
.doc h1{font-size:25px;font-weight:600;line-height:1.2;letter-spacing:-.01em}
|
||||
.doc h1 .ph{color:#c0c8d2;font-style:italic;font-weight:400}
|
||||
|
||||
.doc h2{
|
||||
font-family:'Inter Tight',sans-serif;font-size:11px;font-weight:700;letter-spacing:.11em;
|
||||
text-transform:uppercase;color:var(--ink-3);margin:26px 0 11px;
|
||||
padding-bottom:5px;border-bottom:1px solid var(--line-2);
|
||||
}
|
||||
.doc p{font-size:15px;line-height:1.62;margin-bottom:10px;color:#1c3category}
|
||||
.doc p{color:#1b3049}
|
||||
|
||||
/* campos */
|
||||
.fields{display:grid;grid-template-columns:auto 1fr;gap:0}
|
||||
.fields dt{
|
||||
font-family:'Inter Tight',sans-serif;font-size:11.5px;font-weight:600;color:var(--ink-3);
|
||||
padding:7px 20px 7px 0;border-bottom:1px solid var(--line-2);white-space:nowrap;
|
||||
}
|
||||
.fields dd{font-size:14.5px;padding:7px 0;border-bottom:1px solid var(--line-2)}
|
||||
.fields dt:last-of-type,.fields dd:last-of-type{border-bottom:none}
|
||||
|
||||
.val{transition:.3s}
|
||||
.val.empty{
|
||||
display:inline-block;font-family:'Inter Tight',sans-serif;font-size:11.5px;
|
||||
color:var(--amber);background:var(--amber-bg);border:1px dashed #dcc08a;
|
||||
padding:1px 8px;border-radius:2px;font-style:normal;
|
||||
}
|
||||
.val.fresh{animation:pop .5s ease both}
|
||||
@keyframes pop{0%{background:#fff3d4}100%{background:transparent}}
|
||||
|
||||
.mono{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:500}
|
||||
|
||||
/* tabela de entregáveis */
|
||||
table.ent{width:100%;border-collapse:collapse;font-family:'Inter Tight',sans-serif;margin-top:4px}
|
||||
table.ent th{
|
||||
font-size:10px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ink-3);
|
||||
text-align:left;padding:0 8px 7px 0;border-bottom:1px solid var(--ink);
|
||||
}
|
||||
table.ent th.n,table.ent td.n{text-align:right;padding-right:0}
|
||||
table.ent td{font-size:12.8px;padding:9px 8px 9px 0;border-bottom:1px solid var(--line-2);vertical-align:top}
|
||||
table.ent td.n{font-family:'JetBrains Mono',monospace;font-weight:500}
|
||||
table.ent tr.total td{border-bottom:none;border-top:2px solid var(--ink);font-weight:700;padding-top:9px;font-size:13.2px}
|
||||
table.ent .nm{font-weight:600;color:var(--ink);display:block;line-height:1.35}
|
||||
table.ent .meta{font-size:11px;color:var(--ink-3);display:block;margin-top:2px}
|
||||
.tag{
|
||||
display:inline-block;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;
|
||||
padding:2px 6px;border-radius:2px;background:#e8f4f7;color:var(--teal);
|
||||
}
|
||||
|
||||
/* verificação */
|
||||
.check{margin-top:8px;border:1px solid var(--line);background:#fff}
|
||||
.check-h{
|
||||
font-family:'Inter Tight',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
|
||||
padding:10px 14px;border-bottom:1px solid var(--line-2);color:var(--ink-2);display:flex;align-items:center;gap:8px;
|
||||
}
|
||||
.check-h .score{margin-left:auto;font-family:'JetBrains Mono',monospace;color:var(--green)}
|
||||
.check ul{list-style:none;padding:8px 14px 12px}
|
||||
.check li{
|
||||
font-family:'Inter Tight',sans-serif;font-size:12.5px;padding:5px 0 5px 22px;position:relative;color:var(--ink-2);line-height:1.45;
|
||||
}
|
||||
.check li::before{position:absolute;left:0;top:5px;font-family:'JetBrains Mono',monospace;font-size:12px}
|
||||
.check li.ok::before{content:'✓';color:var(--green)}
|
||||
.check li.warn::before{content:'!';color:var(--amber);font-weight:700;left:4px}
|
||||
.check li.warn{color:#6b4a12}
|
||||
|
||||
/* empty state doc */
|
||||
.doc-empty{
|
||||
max-width:660px;margin:0 auto;padding:70px 40px;text-align:center;
|
||||
border:1px dashed #c3cad4;background:#f7f8fa;
|
||||
}
|
||||
.doc-empty h3{font-family:'Newsreader',serif;font-size:21px;font-weight:500;color:var(--ink-2);margin-bottom:9px}
|
||||
.doc-empty p{font-size:13.5px;color:var(--ink-3);max-width:380px;margin:0 auto;line-height:1.6}
|
||||
|
||||
/* painel base */
|
||||
.kb{padding:22px 26px;overflow-y:auto;flex:1;background:#f1f3f6}
|
||||
.kb-sec{margin-bottom:26px}
|
||||
.kb-sec>h3{
|
||||
font-size:10.5px;font-weight:700;letter-spacing:.11em;text-transform:uppercase;color:var(--ink-3);
|
||||
margin-bottom:11px;padding-bottom:6px;border-bottom:1px solid var(--line);
|
||||
}
|
||||
.rec{
|
||||
background:#fff;border:1px solid var(--line);padding:12px 14px;margin-bottom:8px;
|
||||
display:flex;gap:13px;align-items:flex-start;transition:.18s;
|
||||
}
|
||||
.rec.hit{border-color:var(--teal);box-shadow:0 0 0 2px #d9eef3}
|
||||
.rec .rid{font-family:'JetBrains Mono',monospace;font-size:11.5px;font-weight:700;color:var(--ink);flex:none;width:44px}
|
||||
.rec .rbody{flex:1;min-width:0}
|
||||
.rec .rt{font-size:13.2px;font-weight:600;line-height:1.35}
|
||||
.rec .rm{font-size:11.5px;color:var(--ink-3);margin-top:3px;display:flex;gap:9px;flex-wrap:wrap}
|
||||
.rec .rm span{white-space:nowrap}
|
||||
.kb-note{font-size:12.5px;color:var(--ink-3);line-height:1.55;padding:10px 12px;background:#fff;border-left:2px solid var(--line)}
|
||||
|
||||
@media(max-width:1000px){
|
||||
body{overflow:auto}
|
||||
.app{height:auto}
|
||||
.main{grid-template-columns:1fr}
|
||||
.left{border-right:none;border-bottom:1px solid var(--line);height:auto}
|
||||
.stream{max-height:none}
|
||||
.doc{padding:26px 22px 30px}
|
||||
.doc-wrap{padding:16px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<header class="topbar">
|
||||
<div class="logo">
|
||||
<svg width="19" height="21" viewBox="0 0 20 22" aria-hidden="true">
|
||||
<path d="M10 1l8 4.6v9.2L10 20.4 2 14.8V5.6z" fill="#3fb6d0"/>
|
||||
<path d="M10 5.6l4.3 2.5v4.9L10 15.5l-4.3-2.5V8.1z" fill="#0f2338"/>
|
||||
</svg>
|
||||
Redator de Demandas
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="ctx">Contrato <b>Pregão 29/2025</b> · SES-MG · Rede Mineira de Dados em Saúde</div>
|
||||
<div class="right">
|
||||
<div class="pill" id="pill">Rascunho</div>
|
||||
<button class="btn-reset" onclick="location.reload()">Recomeçar</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ========== ESQUERDA ========== -->
|
||||
<section class="left">
|
||||
<div class="left-head">
|
||||
<div class="eyebrow">Entrevista</div>
|
||||
<div class="step" id="stepLabel">passo 0 de 6</div>
|
||||
</div>
|
||||
|
||||
<div class="stream" id="stream"></div>
|
||||
|
||||
<div class="composer" id="composer">
|
||||
<textarea id="input" placeholder="Cole aqui a demanda como ela chegou — trecho de ata, e-mail, mensagem de Teams. Não precisa organizar."></textarea>
|
||||
<div class="composer-row">
|
||||
<button class="btn" id="send">Analisar demanda</button>
|
||||
<button class="btn ghost" id="example">Usar um exemplo</button>
|
||||
<span class="hint">Nada é enviado — protótipo local</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== DIREITA ========== -->
|
||||
<section class="right">
|
||||
<div class="tabs">
|
||||
<button class="tab on" data-tab="doc">Demanda em construção</button>
|
||||
<button class="tab" data-tab="kb">Base consultada <span class="n" id="kbCount">0</span></button>
|
||||
</div>
|
||||
|
||||
<div class="doc-wrap" id="docWrap">
|
||||
<div class="doc-empty" id="docEmpty">
|
||||
<h3>A demanda se escreve enquanto vocês conversam</h3>
|
||||
<p>Cole o texto bruto ao lado. Cada resposta preenche um campo aqui — enquadramento, entregáveis, USTs e critérios de aceite.</p>
|
||||
</div>
|
||||
<div class="doc" id="doc" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="kb" id="kb" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ============================================================
|
||||
BASE DE CONHECIMENTO SIMULADA
|
||||
============================================================ */
|
||||
const BASE_ENTREGAS = [
|
||||
{id:'SD25', t:'Índice de Capacidade de Integração Municipal', item:'I-05', ust:320, dur:'6 sem', status:'Entregue', tags:['dashboard','score','regulação','municípios']},
|
||||
{id:'SD24', t:'Portal RMDS — prototipagem e nota técnica', item:'I-02', ust:240, dur:'6 sem', status:'Pausado', tags:['portal','adesão','municípios']},
|
||||
{id:'SD22', t:'Ingestão SUS-Fácil — conector e camada bronze', item:'I-02', ust:400, dur:'10 sem', status:'Entregue', tags:['ingestão','regulação','conector','fila']},
|
||||
{id:'SD19', t:'Automação de giro de leito CORE × FHEMIG', item:'I-04', ust:520, dur:'10 sem', status:'Em execução', tags:['leitos','automação','regulação']},
|
||||
{id:'SD17', t:'Busca ativa Outubro Rosa — coorte de mamografia', item:'I-05', ust:480, dur:'9 sem', status:'Entregue', tags:['coorte','fila','algoritmo','painel']},
|
||||
{id:'SD12', t:'Motor de regras vacinais — MG App Vacina em Dia', item:'I-05', ust:440, dur:'9 sem', status:'Entregue', tags:['motor','regras','algoritmo']}
|
||||
];
|
||||
|
||||
const BASE_PLATAFORMA = [
|
||||
{id:'CAP-01', t:'Camada de ingestão e conectores', d:'Conectores para SUS-Fácil, Vivver, Sisreg e e-SUS já homologados. Fonte nova entra como I-02.'},
|
||||
{id:'CAP-04', t:'Curadoria semântica e de-para', d:'Harmonização de nomenclatura entre bases não padronizadas. Disponível como serviço.'},
|
||||
{id:'CAP-07', t:'Produtos analíticos e painéis', d:'Publicação de dashboards na plataforma com controle de acesso por perfil. Enquadra em I-05.'},
|
||||
{id:'CAP-09', t:'Camada de fila e tempo de espera', d:'Ainda não construída. Depende de ingestão contínua do SUS-Fácil (SD22) e do CORE.'}
|
||||
];
|
||||
|
||||
const REGRAS = {
|
||||
timebox:{ 'I-02':{c:40}, 'I-03':{c:40}, 'I-04':{c:80}, 'I-05':{c:80} },
|
||||
nomes:{
|
||||
'I-02':'Desenvolvimento e implantação de plataforma',
|
||||
'I-03':'Capacitação e consultoria pós-implantação',
|
||||
'I-04':'Manutenção, integração e automação',
|
||||
'I-05':'Serviços técnicos de inteligência e produtos analíticos'
|
||||
}
|
||||
};
|
||||
|
||||
const DEMANDA_EXEMPLO =
|
||||
`Pessoal, na reunião de ontem a SUBRAS trouxe de novo a questão da fila de cirurgias eletivas. Hoje ninguém sabe dizer quantas pessoas estão esperando, há quanto tempo, e em qual município elas estão paradas. O secretário pediu uma visão do estado inteiro.
|
||||
|
||||
O Guilherme falou que os dados do SUS-Fácil já estão entrando na plataforma desde a integração do mês passado, então em tese dá pra montar alguma coisa. Precisamos definir o que conta como "fila", porque tem solicitação em busca de leito, tem autorizada sem agendamento, tem paciente que já foi operado e não deu baixa.
|
||||
|
||||
Também precisa cruzar com o Opera Mais pra saber quais municípios recebem incentivo e têm fila grande. A ideia é ter um painel que o pessoal da regulação abra e consiga priorizar. Prazo: querem alguma coisa até o fim do próximo mês.`;
|
||||
|
||||
/* ============================================================
|
||||
ESTADO
|
||||
============================================================ */
|
||||
let step = 0;
|
||||
const sd = {
|
||||
titulo:null, projeto:'RMDS — Produto Analítico de Regulação',
|
||||
problema:null, item:null, entregaveis:[], artefatos:null, gerada:false
|
||||
};
|
||||
const kbHits = new Set();
|
||||
|
||||
const stream = document.getElementById('stream');
|
||||
const composer = document.getElementById('composer');
|
||||
const input = document.getElementById('input');
|
||||
const doc = document.getElementById('doc');
|
||||
const docEmpty = document.getElementById('docEmpty');
|
||||
|
||||
/* ============================================================
|
||||
HELPERS DE UI
|
||||
============================================================ */
|
||||
function msg(who, html, cls=''){
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg ' + cls;
|
||||
d.innerHTML = `<div class="who"><i></i>${who}</div><div class="bubble">${html}</div>`;
|
||||
stream.appendChild(d);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
function think(cb, ms=750){
|
||||
const d = document.createElement('div');
|
||||
d.className='msg';
|
||||
d.innerHTML = `<div class="who"><i></i>Redator</div><div class="bubble"><div class="typing"><span></span><span></span><span></span></div></div>`;
|
||||
stream.appendChild(d);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
setTimeout(()=>{ d.remove(); cb(); }, ms);
|
||||
}
|
||||
function sourcesBlock(list){
|
||||
return `<div class="sources"><div class="sources-h">Consultei na base</div>` +
|
||||
list.map(s=>`<div class="src"><div class="id">${s.id}</div><div class="txt">${s.txt}</div></div>`).join('') +
|
||||
`</div>`;
|
||||
}
|
||||
function options(list){
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'opts';
|
||||
list.forEach(o=>{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'opt' + (o.wide?' wide':'');
|
||||
b.innerHTML = `${o.label}${o.sub?`<small>${o.sub}</small>`:''}`;
|
||||
b.onclick = ()=>{
|
||||
wrap.remove();
|
||||
msg('Você', o.echo || o.label, 'user');
|
||||
o.go();
|
||||
};
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
stream.lastElementChild.querySelector('.bubble').appendChild(wrap);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
function setStep(n){
|
||||
step = n;
|
||||
document.getElementById('stepLabel').textContent = `passo ${n} de 6`;
|
||||
}
|
||||
function hit(id){ kbHits.add(id); document.getElementById('kbCount').textContent = kbHits.size; renderKB(); }
|
||||
|
||||
/* ============================================================
|
||||
FLUXO DA ENTREVISTA
|
||||
============================================================ */
|
||||
document.getElementById('example').onclick = ()=>{ input.value = DEMANDA_EXEMPLO; input.focus(); };
|
||||
document.getElementById('send').onclick = ()=>{
|
||||
const v = input.value.trim();
|
||||
if(!v) return;
|
||||
composer.style.display='none';
|
||||
msg('Você', v.split('\n\n').map(p=>`<p>${p}</p>`).join(''), 'user');
|
||||
setStep(1);
|
||||
q1();
|
||||
};
|
||||
|
||||
/* ---- 1. leitura e primeira devolutiva ---- */
|
||||
function q1(){
|
||||
think(()=>{
|
||||
['SD22','SD17','CAP-09'].forEach(hit);
|
||||
msg('Redator', `
|
||||
<p>Li a demanda. O que eu entendi: a SES-MG não consegue enxergar a fila de cirurgias eletivas do estado — nem volume, nem tempo de espera, nem distribuição por município — e quer um painel que permita priorizar.</p>
|
||||
<p>Antes de enquadrar, tem uma coisa que preciso confirmar, porque muda o item e o custo.</p>
|
||||
${sourcesBlock([
|
||||
{id:'SD22', txt:'<b>Ingestão SUS-Fácil</b> — conector e camada bronze entregues. A fonte já está na plataforma, então não é preciso construir ingestão de novo.'},
|
||||
{id:'CAP-09', txt:'<b>Camada de fila e tempo de espera</b> — ainda não construída na plataforma. É a peça que falta.'},
|
||||
{id:'SD17', txt:'<b>Busca ativa Outubro Rosa</b> — precedente de coorte + painel, enquadrado em I-05, 480 UST.'}
|
||||
])}
|
||||
<p style="margin-top:14px">A definição de fila — o que conta como paciente esperando — já existe em algum lugar?</p>
|
||||
`, 'bot');
|
||||
options([
|
||||
{label:'Não existe, precisa ser construída com a SUBRAS', sub:'Vai exigir descoberta negocial e validação de regra', wide:true, echo:'Não existe. Vamos ter que definir com eles.', go:()=>q2('nova')},
|
||||
{label:'Existe, mas está em documento antigo e desatualizado', sub:'Descoberta mais curta, só validação', wide:true, echo:'Existe algo antigo, mas precisa revalidar.', go:()=>q2('revisar')}
|
||||
]);
|
||||
}, 1100);
|
||||
}
|
||||
|
||||
/* ---- 2. natureza do trabalho → item ---- */
|
||||
function q2(origem){
|
||||
sd.problema = origem;
|
||||
setStep(2);
|
||||
think(()=>{
|
||||
msg('Redator', `
|
||||
<p>Certo. Então a demanda tem duas naturezas possíveis e eu preciso saber onde está o peso do trabalho — é isso que define o item do contrato.</p>
|
||||
<p class="quiet">O painel em si é inteligência. Mas se for preciso trazer fonte nova ou construir conector, aí entra plataforma, e vira entregável separado.</p>
|
||||
`, 'bot');
|
||||
options([
|
||||
{label:'Só o painel e a regra de fila', sub:'Os dados do SUS-Fácil já estão na plataforma', wide:true, echo:'Só painel e regra. Os dados já estão lá.', go:()=>q3('I-05')},
|
||||
{label:'Painel + trazer o CORE Saúde como fonte nova', sub:'Cruza dois itens — I-02 para a ingestão, I-05 para o painel', wide:true, echo:'Vamos precisar do CORE também.', go:()=>q3('duplo')}
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 3. enquadramento ---- */
|
||||
function q3(modo){
|
||||
sd.item = modo === 'duplo' ? 'I-05 + I-02' : 'I-05';
|
||||
setStep(3);
|
||||
think(()=>{
|
||||
let extra = '';
|
||||
if(modo === 'duplo'){
|
||||
extra = `<div class="flag"><b>Atenção.</b> Trazer o CORE agora abre uma frente de I-02 com dependência externa — a SUBASS informou que a extração do CORE está suspensa por ajustes no sistema novo. Recomendo abrir como SD separada e não travar o painel.</div>`;
|
||||
hit('SD22');
|
||||
}
|
||||
hit('CAP-07');
|
||||
msg('Redator', `
|
||||
<p>Enquadrei em <b>I-05</b>. Painel gerencial, algoritmo de classificação e priorização — é a regra do manual: dashboard, índice ou análise vai em I-05, não em I-04.</p>
|
||||
${extra}
|
||||
${sourcesBlock([
|
||||
{id:'CAP-07', txt:'<b>Produtos analíticos e painéis</b> — publicação com controle de acesso por perfil já é capacidade da plataforma. Não precisa ser construída.'},
|
||||
{id:'SD17', txt:'Mesmo enquadramento e escopo comparável: coorte + painel de priorização em I-05.'}
|
||||
])}
|
||||
<p style="margin-top:14px">Agora os entregáveis. Quantas frentes de trabalho você enxerga até o painel no ar?</p>
|
||||
`, 'bot');
|
||||
options([
|
||||
{label:'Três — descobrir a regra, desenhar o painel, construir', sub:'Padrão da maioria das SDs de produto analítico', wide:true, echo:'Três: descoberta, design e construção.', go:()=>q4('tres')},
|
||||
{label:'Quatro — com arquitetura do modelo de dados separada', sub:'Se a modelagem da fila for complexa o bastante', wide:true, echo:'Quatro, incluindo arquitetura.', go:()=>q4('quatro')}
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 4. dimensionamento ---- */
|
||||
function q4(qtd){
|
||||
setStep(4);
|
||||
const base = [
|
||||
{n:'Discovery da regra de fila cirúrgica', tipo:'Descoberta', item:'I-05', sem:3, tb:40},
|
||||
{n:'Especificação do painel de priorização', tipo:'Design', item:'I-05', sem:2, tb:40}
|
||||
];
|
||||
if(qtd === 'quatro') base.push({n:'Modelo de dados da fila e tempo de espera', tipo:'Arquitetura', item:'I-05', sem:2, tb:40});
|
||||
base.push({n:'Painel de fila cirúrgica publicado', tipo:'Construção', item:'I-05', sem:3, tb:80});
|
||||
sd.entregaveis = base;
|
||||
renderDoc();
|
||||
|
||||
think(()=>{
|
||||
const total = base.reduce((a,e)=>a+e.sem*e.tb,0);
|
||||
msg('Redator', `
|
||||
<p>Montei o quadro ao lado. Dimensionei a descoberta em 3 semanas porque definir fila com a SUBRAS envolve área finalística e casos de borda — solicitação em busca de leito, autorizada sem agendamento, alta sem baixa. Isso não fecha em duas.</p>
|
||||
<p>Dá <b class="mono">${total} UST</b>. A construção pesa porque em I-05 o time-box é de 80h por semana.</p>
|
||||
<div class="flag"><b>Prazo.</b> A demanda pede o painel até o fim do próximo mês. Esse desenho leva ${base.reduce((a,e)=>a+e.sem,0)} semanas encadeadas. Ou a expectativa é recalibrada, ou o discovery cai para 2 semanas com escopo de fila reduzido a cirurgias eletivas apenas.</div>
|
||||
<p style="margin-top:14px">Última coisa: o que já existe hoje que sirva de comprovação?</p>
|
||||
`, 'bot');
|
||||
options([
|
||||
{label:'Ata da reunião com a SUBRAS e o pedido do secretário', sub:'Lastro para a descoberta', wide:true, echo:'Tenho a ata e o registro do pedido.', go:()=>q5(true)},
|
||||
{label:'Nada formalizado ainda — a demanda veio em reunião', sub:'A SD abre a demanda antes da execução', wide:true, echo:'Nada ainda. É demanda nova.', go:()=>q5(false)}
|
||||
]);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
/* ---- 5. fechamento ---- */
|
||||
function q5(temLastro){
|
||||
sd.artefatos = temLastro;
|
||||
setStep(5);
|
||||
think(()=>{
|
||||
msg('Redator', `
|
||||
<p>${temLastro
|
||||
? 'Bom. A ata entra como artefato comprobatório do entregável de descoberta e ancora a data de abertura.'
|
||||
: 'Então essa SD abre demanda futura, não formaliza execução passada. Isso simplifica: os artefatos são projetados, não recuperados.'}</p>
|
||||
<p>Tenho o suficiente para escrever. Vou gerar a SD com quadro de entregáveis, enquadramento no TR, artefatos e critérios de aceite por entregável.</p>
|
||||
`, 'bot');
|
||||
options([
|
||||
{label:'Gerar a demanda', wide:true, echo:'Pode gerar.', go:()=>gerar()}
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
function gerar(){
|
||||
setStep(6);
|
||||
document.getElementById('pill').textContent = 'Pronta para revisão';
|
||||
document.getElementById('pill').style.background = 'rgba(63,182,208,.2)';
|
||||
document.getElementById('pill').style.color = '#7fd8ea';
|
||||
sd.gerada = true;
|
||||
sd.titulo = 'Painel de Fila Cirúrgica Eletiva';
|
||||
renderDoc();
|
||||
think(()=>{
|
||||
msg('Redator', `
|
||||
<p>Pronta. Rodei o checklist de validação e passou em tudo, com uma ressalva registrada ao lado sobre o prazo.</p>
|
||||
<p class="quiet">O que você faz agora: revisa o contexto — é a parte que precisa da sua leitura política —, confirma o título literal do item contra o TR e exporta.</p>
|
||||
<div class="flag"><b>Nesta versão do protótipo</b> a exportação para .docx e o envio ao fluxo de aprovação ainda não estão ligados.</div>
|
||||
`, 'bot');
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className='opts';
|
||||
wrap.innerHTML = `<button class="opt" disabled style="opacity:.45;cursor:not-allowed">Exportar .docx</button><button class="opt" disabled style="opacity:.45;cursor:not-allowed">Enviar para aprovação</button>`;
|
||||
stream.lastElementChild.querySelector('.bubble').appendChild(wrap);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RENDER DO DOCUMENTO
|
||||
============================================================ */
|
||||
function ph(txt){ return `<span class="val empty">${txt}</span>`; }
|
||||
|
||||
function renderDoc(){
|
||||
docEmpty.style.display='none';
|
||||
doc.style.display='block';
|
||||
|
||||
const total = sd.entregaveis.reduce((a,e)=>a+e.sem*e.tb,0);
|
||||
const semanas = sd.entregaveis.reduce((a,e)=>a+e.sem,0);
|
||||
|
||||
let linhas = sd.entregaveis.map((e,i)=>`
|
||||
<tr>
|
||||
<td class="n" style="width:22px;color:#8b9aab">${i+1}</td>
|
||||
<td><span class="nm">${e.n}</span><span class="meta">${e.tipo} · time-box ${e.tb}h/semana</span></td>
|
||||
<td style="width:46px"><span class="tag">${e.item}</span></td>
|
||||
<td class="n" style="width:52px">${e.sem} sem</td>
|
||||
<td class="n" style="width:56px">${e.sem*e.tb}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
if(sd.entregaveis.length){
|
||||
linhas += `<tr class="total"><td></td><td>Total</td><td></td><td class="n">${semanas} sem</td><td class="n">${total}</td></tr>`;
|
||||
}
|
||||
|
||||
doc.innerHTML = `
|
||||
<div class="doc-head">
|
||||
<div class="doc-kicker">Solicitação de Demanda · rascunho automático</div>
|
||||
<h1>${sd.titulo ? sd.titulo : '<span class="ph">Título a definir</span>'}</h1>
|
||||
</div>
|
||||
|
||||
<dl class="fields">
|
||||
<dt>Projeto</dt><dd>${sd.projeto}</dd>
|
||||
<dt>Contratante</dt><dd>Secretaria de Estado de Saúde de Minas Gerais</dd>
|
||||
<dt>Contrato</dt><dd class="mono">Pregão Eletrônico nº 29/2025 — Processo 1321127</dd>
|
||||
<dt>Item do TR</dt><dd>${sd.item ? `<b>${sd.item}</b> — ${REGRAS.nomes['I-05']}` : ph('aguardando enquadramento')}</dd>
|
||||
<dt>Total de UST</dt><dd>${total ? `<span class="mono">${total}</span>` : ph('aguardando entregáveis')}</dd>
|
||||
<dt>Entregáveis</dt><dd>${sd.entregaveis.length || ph('—')}</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Contexto e escopo</h2>
|
||||
${sd.gerada ? `
|
||||
<p>A SES-MG não dispõe hoje de visão consolidada da fila de cirurgias eletivas. Não é possível responder quantos pacientes aguardam, há quanto tempo e em que municípios a espera se concentra — o que impede priorização e enfraquece a gestão do Opera Mais, programa que financia produção cirúrgica cuja demanda represada permanece invisível.</p>
|
||||
<p>Os dados do SUS-Fácil já foram integrados à plataforma. O que falta é a camada de interpretação: definir o que constitui fila, tratar os estados intermediários da solicitação e transformar registros de regulação em tempo de espera mensurável por município e procedimento.</p>
|
||||
<p>Esta demanda entrega a regra de fila validada com a área finalística e o painel que a torna operável pela regulação estadual.</p>
|
||||
` : `<p style="color:#93a1b2;font-style:italic">O contexto é redigido ao final da entrevista, a partir das respostas.</p>`}
|
||||
|
||||
<h2>Quadro de entregáveis</h2>
|
||||
${sd.entregaveis.length ? `
|
||||
<table class="ent">
|
||||
<thead><tr><th></th><th>Entregável</th><th>Item</th><th class="n">Prazo</th><th class="n">UST</th></tr></thead>
|
||||
<tbody>${linhas}</tbody>
|
||||
</table>
|
||||
` : `<p style="color:#93a1b2;font-style:italic">Os entregáveis aparecem aqui conforme a conversa avança.</p>`}
|
||||
|
||||
${sd.gerada ? `
|
||||
<h2>Enquadramento no Termo de Referência</h2>
|
||||
<dl class="fields">
|
||||
<dt>Item</dt><dd><b>I-05</b> — ${REGRAS.nomes['I-05']}</dd>
|
||||
<dt>Aderência</dt><dd style="font-size:14px;line-height:1.55">
|
||||
Concepção do algoritmo de classificação de fila e tempo de espera a partir dos registros de regulação;
|
||||
desenvolvimento do modelo de priorização por município e procedimento;
|
||||
construção e publicação do painel gerencial com controle de acesso por perfil.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Verificação automática</h2>
|
||||
<div class="check">
|
||||
<div class="check-h">Checklist do manual <span class="score">10 de 11</span></div>
|
||||
<ul>
|
||||
<li class="ok">Cada entregável tem item definido</li>
|
||||
<li class="ok">Dashboard e algoritmo enquadrados em I-05</li>
|
||||
<li class="ok">Nenhum entregável abaixo de 2 nem acima de 4 semanas</li>
|
||||
<li class="ok">Time-box de construção correto para I-05 (80h)</li>
|
||||
<li class="ok">UST de cada linha confere com time-box × semanas</li>
|
||||
<li class="ok">Soma das USTs bate com o cabeçalho</li>
|
||||
<li class="ok">Entregáveis encadeados, sem sobreposição de período</li>
|
||||
<li class="ok">Produto com nome único ao longo do documento</li>
|
||||
<li class="ok">Todo critério de aceite é verificável por terceiro</li>
|
||||
<li class="ok">Nenhum campo confessa irregularidade</li>
|
||||
<li class="warn">Prazo solicitado pelo demandante é menor que a duração desenhada — precisa de repactuação antes do envio</li>
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
doc.querySelectorAll('.val').forEach(v=>v.classList.add('fresh'));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAINEL DA BASE
|
||||
============================================================ */
|
||||
function renderKB(){
|
||||
const el = document.getElementById('kb');
|
||||
el.innerHTML = `
|
||||
<div class="kb-sec">
|
||||
<h3>Entregas anteriores · ${BASE_ENTREGAS.length} registros</h3>
|
||||
${BASE_ENTREGAS.map(r=>`
|
||||
<div class="rec ${kbHits.has(r.id)?'hit':''}">
|
||||
<div class="rid">${r.id}</div>
|
||||
<div class="rbody">
|
||||
<div class="rt">${r.t}</div>
|
||||
<div class="rm"><span>${r.item}</span><span>${r.ust} UST</span><span>${r.dur}</span><span>${r.status}</span></div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="kb-sec">
|
||||
<h3>Capacidades da plataforma</h3>
|
||||
${BASE_PLATAFORMA.map(r=>`
|
||||
<div class="rec ${kbHits.has(r.id)?'hit':''}">
|
||||
<div class="rid">${r.id}</div>
|
||||
<div class="rbody">
|
||||
<div class="rt">${r.t}</div>
|
||||
<div class="rm" style="display:block;line-height:1.45">${r.d}</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="kb-sec">
|
||||
<h3>Regras aplicadas</h3>
|
||||
<div class="kb-note">Time-box de 40h/semana em Descoberta, Design e Arquitetura. Construção a 40h em I-02 e I-03, e a 80h em I-04 e I-05. Cada entregável entre 2 e 4 semanas. Entregáveis de itens diferentes nunca somam na mesma linha.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
renderKB();
|
||||
|
||||
/* tabs */
|
||||
document.querySelectorAll('.tab').forEach(t=>{
|
||||
t.onclick = ()=>{
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
|
||||
t.classList.add('on');
|
||||
const isDoc = t.dataset.tab === 'doc';
|
||||
document.getElementById('docWrap').style.display = isDoc ? 'block' : 'none';
|
||||
document.getElementById('kb').style.display = isDoc ? 'none' : 'block';
|
||||
};
|
||||
});
|
||||
|
||||
/* abertura */
|
||||
msg('Redator', `
|
||||
<p>Me conta a demanda do jeito que ela chegou. Pode ser trecho de ata, e-mail encaminhado, print de conversa — não precisa organizar nada.</p>
|
||||
<p class="quiet">Eu leio, comparo com o que já entregamos e com o que a plataforma já faz, e vou te perguntando só o que ainda falta para fechar a SD.</p>
|
||||
`, 'bot');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"$comment": "Fonte única de verdade das regras determinísticas do Manual de Redação de SD. Consumida (a) pelo prompt do redator como referência e (b) pelo validador em código do app. Ao mudar o TR, edite APENAS este arquivo.",
|
||||
"versao": "1.0.0",
|
||||
"atualizado_em": "2026-08-07",
|
||||
|
||||
"contrato": {
|
||||
"contratante": "Secretaria de Estado de Saúde de Minas Gerais (SES-MG)",
|
||||
"contratada": "Isis Saúde",
|
||||
"instrumento": "Pregão Eletrônico nº 29/2025 — Processo 1321127",
|
||||
"programa": "RMDS — Rede Mineira de Dados em Saúde"
|
||||
},
|
||||
|
||||
"itens": {
|
||||
"I-02": {
|
||||
"item_tr_numero": 2,
|
||||
"codigo_catmas": "000130940",
|
||||
"titulo_literal": "Desenvolvimento e implantação da tecnologia blockchain",
|
||||
"apelido_manual": "Desenvolvimento e implantação de plataforma",
|
||||
"descricao_tr": "Setup de implantação e configuração da solução tecnológica, consistindo no levantamento dos requisitos necessários, na implantação em unidades das Redes de Assistência à Saúde do Estado de Minas Gerais e na realização das integrações com os sistemas de informação (softwares) e/ou bases de dados de saúde utilizados pela Contratante, incluindo: análise das necessidades das áreas de negócio da SES-MG; avaliação técnica dos sistemas de TI existentes; validação operacional; customização e parametrização (regras de negócio, fluxos de trabalho e interfaces); mapeamento de dados e integrações; desenvolvimento de interfaces de integração (APIs, middleware, sistemas legados); e testes de integração. (TR, subitem 1.6.4.1 — descrição do Item 2)",
|
||||
"complementacao_catmas": "Executar atividades de desenvolvimento de software para soluções com blockchain, incluindo contratos inteligentes e atividades relacionadas com a integração com sistemas legados.",
|
||||
"pool_ust_total": 50400,
|
||||
"timebox_h": { "descoberta": 40, "design": 40, "arquitetura": 40, "construcao": 40 }
|
||||
},
|
||||
"I-03": {
|
||||
"item_tr_numero": 3,
|
||||
"codigo_catmas": "000097144",
|
||||
"titulo_literal": "Desenvolvimento e Manutenção de Sistemas de Informação Utilizando Metodologia Ágil",
|
||||
"apelido_manual": "Capacitação e consultoria pós-implantação",
|
||||
"descricao_tr": "Serviços de treinamento de usuários e de consultoria pós-implantação de solução tecnológica, componentes críticos para garantir que uma nova solução ou sistema seja adotado com sucesso pela SES-MG. Envolvem uma série de atividades projetadas para maximizar a eficiência operacional e a satisfação dos usuários com a nova solução tecnológica implantada, enquanto eixo estruturante da Secretaria. (TR, subitem 1.6.4.2 — descrição do Item 3)",
|
||||
"pool_ust_total": 4800,
|
||||
"timebox_h": { "descoberta": 40, "design": 40, "arquitetura": 40, "construcao": 40 },
|
||||
"alerta_redacao": "O título CATMAS do Item 3 é idêntico ao do Item 4 e não menciona treinamento/consultoria. Na Seção 5 da SD, cite o número do item + código CATMAS + a descrição de serviço do subitem 1.6.4.2 do TR para evitar reclassificação."
|
||||
},
|
||||
"I-04": {
|
||||
"item_tr_numero": 4,
|
||||
"codigo_catmas": "000097144",
|
||||
"titulo_literal": "Desenvolvimento e Manutenção de Sistemas de Informação Utilizando Metodologia Ágil",
|
||||
"apelido_manual": "Manutenção, integração e automação",
|
||||
"descricao_tr": "Serviços de Manutenção: (i) Corretiva: correção de defeitos identificados nos sistemas de informação em produção; (ii) Adaptativa: adequação da solução em decorrência de mudanças que não impliquem inserção, alteração ou exclusão de requisitos funcionais; (iii) Perfectiva: mudanças de interface e layout, sem alteração de funcionalidades sob o ponto de vista do usuário; (iv) Evolutiva: mudanças em requisitos funcionais da solução já concluídos, que impliquem inclusão, alteração e/ou exclusão de funcionalidade. Serviços de integrações e automações: automatizar tarefas repetitivas e baseadas em regras e processos de negócio de interesse da Contratante, integrações de softwares e bases de dados e automações de processos para entrada ou saída de informações de maneira automática (coleta de dados, preenchimento de formulários, processamento de transações e interações com sistemas de software, sem intervenção humana). (TR, subitem 1.6.4.3 — descrição do Item 4)",
|
||||
"pool_ust_total": 15120,
|
||||
"timebox_h": { "descoberta": 40, "design": 40, "arquitetura": 40, "construcao": 80 },
|
||||
"alerta_redacao": "Título CATMAS idêntico ao do Item 3. Na Seção 5, distinga pelo número do item e pela descrição de serviço do subitem 1.6.4.3 do TR."
|
||||
},
|
||||
"I-05": {
|
||||
"item_tr_numero": 5,
|
||||
"codigo_catmas": "0000129720",
|
||||
"titulo_literal": "Serviços Técnicos de Inteligência Artificial (IA) para Desenvolvimento de funcionalidades para Solução / Sistema",
|
||||
"apelido_manual": "Serviços técnicos de inteligência e produtos analíticos de gestão em saúde",
|
||||
"descricao_tr": "Serviços de desenvolvimento e/ou apoio de inteligência em saúde que visam criar modelos de inteligência artificial, algoritmos e dashboards que auxiliam na gestão operacional. Incluem a extração de fenótipos de saúde, clusterização de pacientes, apuração de Retorno sobre o Investimento (ROI) relativo a projetos orientados pela SES-MG e identificação de discrepâncias nos processos da Secretaria. São gerados dashboards personalizados, estudos e relatórios para identificar problemas e fornecer soluções eficazes aos gestores da SES-MG, com foco em otimizar a coordenação do acesso e a continuidade do cuidado. Serviços de Gestão e Inteligência em Saúde: suporte ao processo de gestão executado pelas áreas demandantes, baseados na ciência de dados e na inteligência gerada pelas informações produzidas com o uso da solução tecnológica. (TR, subitem 1.6.4.4 — descrição do Item 5)",
|
||||
"pool_ust_total": 30240,
|
||||
"timebox_h": { "descoberta": 40, "design": 40, "arquitetura": 40, "construcao": 80 }
|
||||
}
|
||||
},
|
||||
|
||||
"observacoes_tr": {
|
||||
"fonte": "Anexo I — Termo de Referência do Pregão Eletrônico nº 29/2025 (SEI 127100818), Tabela I e subitens 1.6.4 e 4.1.1.7",
|
||||
"definicao_ust": "Cada UST (Unidade de Serviço Técnico) representa uma hora de trabalho efetivo; o cálculo baseia-se na alocação proporcional dos membros da equipe, sem fatores multiplicadores (TR 4.1.1.3.5.5).",
|
||||
"sprints": "Sprints de 2 a 4 semanas, iniciando imediatamente após a conclusão da anterior (TR 4.1.1.3.5.6). Time-box confirmado: 40h nas sprints de Descoberta, Design e Arquitetura; Construção 40h nos Itens 2 e 3 e 80h nos Itens 4 e 5 (TR 4.1.1.7.1).",
|
||||
"item_1_fora_do_escopo_de_sd": "O Item 1 do TR (Rede de interconectividade — SaaS, CATMAS 000143251) é a licença/assinatura da plataforma, medida em unidades de integração, não em UST. Não gera SD de entregáveis; por isso não há I-01 neste arquivo.",
|
||||
"inconsistencia_interna_do_tr": "O TR numera os serviços de duas formas: os subitens 1.6.4.x chamam implantação de 'Item 2', treinamento de 'Item 3', manutenção de 'Item 4' e IA de 'Item 5' (coerente com a Tabela I, com a tabela de time-box e com os quantitativos de UST); já a tabela 'Detalhamento dos serviços sob demanda' desloca tudo em -1 (implantação como 'Item 1' etc.). Este arquivo adota a numeração da Tabela I / 1.6.4. Ao citar o TR na SD, referencie sempre o número do item da Tabela I + código CATMAS.",
|
||||
"restricao_perfis": "O mesmo profissional não pode executar tarefas em duas demandas simultâneas, salvo quando, respeitado o limite de 40 horas semanais, o percentual de alocação não exigir dedicação exclusiva (TR 4.1.1.7.3.5) — base da regra de não sobreposição de entregáveis encadeados.",
|
||||
"sla": "Indicadores contratuais: IBE (Itens de Backlog Entregues) ≥ 90% e ISP (Satisfação do Dono do Produto) ≥ 80%, com glosas por faixa (TR 5.3.4). Critérios de aceite bem escritos na SD alimentam diretamente o IBE."
|
||||
},
|
||||
|
||||
"tipos_entregavel": ["Descoberta", "Design", "Arquitetura", "Construção"],
|
||||
|
||||
"limites": {
|
||||
"semanas_min": 2,
|
||||
"semanas_max": 4,
|
||||
"semanas_tipicas": [2, 3],
|
||||
"formula_ust": "ust = timebox_h × semanas"
|
||||
},
|
||||
|
||||
"dimensionamento_semanas": [
|
||||
{ "semanas": 2, "quando": "Escopo simples e fechado, poucas dependências" },
|
||||
{ "semanas": 3, "quando": "Escopo médio, exige reuniões e validação com áreas" },
|
||||
{ "semanas": 4, "quando": "Escopo complexo, múltiplos stakeholders, muita incerteza" }
|
||||
],
|
||||
|
||||
"artefatos_minimos": [
|
||||
{ "ust_min": 0, "ust_max": 80, "artefatos": 1 },
|
||||
{ "ust_min": 81, "ust_max": 160, "artefatos": 2 },
|
||||
{ "ust_min": 161, "ust_max": null, "artefatos": 3 }
|
||||
],
|
||||
|
||||
"regras_de_data": {
|
||||
"encadeados_nao_sobrepoem": "Entregáveis com dependência entre si não podem compartilhar período (mesmos perfis a 200% é achado de auditoria).",
|
||||
"paralelos_exigem_declaracao": "Entregáveis paralelos podem compartilhar período apenas com times distintos, e o paralelismo deve estar declarado em nota na SD.",
|
||||
"data_final_habilita_faturamento": "A data final do entregável deve ser coerente com a data dos artefatos que o comprovam.",
|
||||
"prazo_cabecalho": "O prazo de execução do cabeçalho é a duração de calendário, não a soma das semanas quando há paralelismo."
|
||||
},
|
||||
|
||||
"quebra_de_entregaveis": {
|
||||
"quebrar_quando": [
|
||||
"Escopo excede 4 semanas (teto por entregável)",
|
||||
"Frentes paralelas com times distintos",
|
||||
"Um pedaço pode ser faturado antes do outro",
|
||||
"Atividades pertencem a itens diferentes (obrigatório separar)"
|
||||
],
|
||||
"nao_quebrar_quando": [
|
||||
"Apenas para 'ter mais entregáveis' — infla o orçamento sem entregar mais",
|
||||
"O resultado só faz sentido inteiro (metade de um dashboard não é faturável)"
|
||||
],
|
||||
"tipos_nao_obrigatorios": "Nem toda demanda tem os quatro tipos. Tipo inventado para 'ficar completo' é glosa esperando acontecer."
|
||||
},
|
||||
|
||||
"checklist": [
|
||||
{ "id": "ENQ-01", "grupo": "enquadramento", "verificador": "codigo", "regra": "Cada entregável tem item definido (I-02 / I-03 / I-04 / I-05)" },
|
||||
{ "id": "ENQ-02", "grupo": "enquadramento", "verificador": "julgamento", "regra": "Produto novo está no I-02, não no I-04" },
|
||||
{ "id": "ENQ-03", "grupo": "enquadramento", "verificador": "julgamento", "regra": "Dashboard, índice ou algoritmo está no I-05, não no I-04" },
|
||||
{ "id": "ENQ-04", "grupo": "enquadramento", "verificador": "julgamento", "regra": "Automação de tarefa repetitiva está no I-04" },
|
||||
{ "id": "ENQ-05", "grupo": "enquadramento", "verificador": "julgamento", "regra": "Treinamento pós-implantação está no I-03" },
|
||||
{ "id": "ENQ-06", "grupo": "enquadramento", "verificador": "codigo", "regra": "Entregáveis de itens diferentes estão em linhas separadas" },
|
||||
{ "id": "ENQ-07", "grupo": "enquadramento", "verificador": "codigo", "regra": "Há subtotal por item quando a SD cruza itens" },
|
||||
|
||||
{ "id": "CAL-01", "grupo": "calculo", "verificador": "codigo", "regra": "Cada entregável entre 2 e 4 semanas" },
|
||||
{ "id": "CAL-02", "grupo": "calculo", "verificador": "codigo", "regra": "UST de cada linha = timebox × semanas" },
|
||||
{ "id": "CAL-03", "grupo": "calculo", "verificador": "codigo", "regra": "Timebox de Construção correto (40h em I-02/I-03, 80h em I-04/I-05)" },
|
||||
{ "id": "CAL-04", "grupo": "calculo", "verificador": "codigo", "regra": "Soma da coluna UST bate com o cabeçalho" },
|
||||
{ "id": "CAL-05", "grupo": "calculo", "verificador": "codigo", "regra": "Entregáveis encadeados sem sobreposição de período" },
|
||||
{ "id": "CAL-06", "grupo": "calculo", "verificador": "codigo", "regra": "Paralelismo, quando existe, está declarado em nota" },
|
||||
{ "id": "CAL-07", "grupo": "calculo", "verificador": "codigo", "regra": "Prazo do cabeçalho reflete o calendário, não a soma das semanas" },
|
||||
|
||||
{ "id": "DOC-01", "grupo": "documento", "verificador": "julgamento", "regra": "Nome do projeto é o desta demanda, não copiado de outra SD" },
|
||||
{ "id": "DOC-02", "grupo": "documento", "verificador": "codigo", "regra": "Número do item e título do item correspondem entre si" },
|
||||
{ "id": "DOC-03", "grupo": "documento", "verificador": "codigo", "regra": "Título do item confere com a redação literal do TR (regras-sd.json)" },
|
||||
{ "id": "DOC-04", "grupo": "documento", "verificador": "julgamento", "regra": "Números do contexto batem com os artefatos entregues" },
|
||||
{ "id": "DOC-05", "grupo": "documento", "verificador": "julgamento", "regra": "Produto tem um nome só ao longo de todo o documento" },
|
||||
{ "id": "DOC-06", "grupo": "documento", "verificador": "julgamento", "regra": "Nenhum campo do documento traz anotação de processo interno fora de lugar" },
|
||||
{ "id": "DOC-07", "grupo": "documento", "verificador": "codigo", "regra": "Cada entregável tem o mínimo de artefatos comprobatórios da sua faixa de UST" },
|
||||
{ "id": "DOC-08", "grupo": "documento", "verificador": "usuario", "regra": "Todo artefato citado existe de fato e foi confirmado pelo usuário" },
|
||||
{ "id": "DOC-09", "grupo": "documento", "verificador": "codigo", "regra": "Data final de cada entregável é coerente com a data dos seus artefatos" },
|
||||
{ "id": "DOC-10", "grupo": "documento", "verificador": "julgamento", "regra": "Todo critério de aceite é verificável por terceiro" },
|
||||
{ "id": "DOC-11", "grupo": "documento", "verificador": "julgamento", "regra": "Seção 5 preenchida com aderência específica, não genérica" }
|
||||
]
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "schema-sd.json",
|
||||
"title": "Solicitação de Demanda (SD) — RMDS / SES-MG",
|
||||
"$comment": "Contrato de saída: o redator (LLM) devolve a SD SEMPRE neste formato. O app renderiza o documento a partir dele e roda o validador de código sobre ele. Versão 1.0.0.",
|
||||
"type": "object",
|
||||
"required": ["cabecalho", "objetivo", "contexto", "entregaveis", "enquadramento_tr", "validacao"],
|
||||
|
||||
"properties": {
|
||||
|
||||
"cabecalho": {
|
||||
"type": "object",
|
||||
"required": ["projeto", "numero_sd", "versao", "data_abertura", "periodo_execucao", "itens_tr", "total_ust", "total_ust_extenso", "prazo_execucao_semanas", "quantidade_entregaveis"],
|
||||
"properties": {
|
||||
"titulo": { "type": "string", "description": "Título da SD (nome do produto/resultado)" },
|
||||
"projeto": { "type": "string", "description": "Nome do projeto real DESTA demanda — campo mais copiado por engano" },
|
||||
"numero_sd": { "type": "string", "description": "Sequencial, ex.: SD-26" },
|
||||
"versao": { "type": "string", "description": "V1, V2..." },
|
||||
"data_abertura": { "type": "string", "format": "date", "description": "DD/MM/AAAA no render; ISO aqui" },
|
||||
"periodo_execucao": {
|
||||
"type": "object",
|
||||
"required": ["inicio", "fim"],
|
||||
"properties": {
|
||||
"inicio": { "type": "string", "format": "date", "description": "Data inicial do primeiro entregável" },
|
||||
"fim": { "type": "string", "format": "date", "description": "Data final do último entregável" }
|
||||
}
|
||||
},
|
||||
"itens_tr": {
|
||||
"type": "array",
|
||||
"items": { "enum": ["I-02", "I-03", "I-04", "I-05"] },
|
||||
"minItems": 1,
|
||||
"description": "Todos os itens que a SD cruza"
|
||||
},
|
||||
"total_ust": { "type": "integer", "description": "Soma dos entregáveis — CALCULADO PELO APP, o LLM apenas propõe" },
|
||||
"total_ust_extenso": { "type": "string", "description": "Número por extenso" },
|
||||
"prazo_execucao_semanas": { "type": "integer", "description": "Duração de CALENDÁRIO, não soma das semanas quando há paralelismo" },
|
||||
"quantidade_entregaveis": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
|
||||
"objetivo": {
|
||||
"type": "string",
|
||||
"description": "Dois a quatro períodos. O que a SD formaliza e qual resultado permite alcançar. Não repete o contexto."
|
||||
},
|
||||
|
||||
"contexto": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 5,
|
||||
"items": { "type": "string" },
|
||||
"description": "Parágrafos na ordem: (1) problema de gestão, (2) insumo disponível e por que não basta, (3) o que a demanda entrega, (4) achado/resultado quando houver, (5) desdobramento. Números reais e verificáveis."
|
||||
},
|
||||
|
||||
"entregaveis": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["n", "nome", "tipo", "item_tr", "semanas", "timebox_h", "ust", "periodo", "resumo", "atividades", "artefatos", "criterios_aceite"],
|
||||
"properties": {
|
||||
"n": { "type": "integer", "description": "Posição no quadro" },
|
||||
"nome": { "type": "string", "description": "Nome do artefato final, ex.: 'Painel de fila cirúrgica publicado'" },
|
||||
"tipo": { "enum": ["Descoberta", "Design", "Arquitetura", "Construção"] },
|
||||
"item_tr": { "enum": ["I-02", "I-03", "I-04", "I-05"] },
|
||||
"semanas": { "type": "integer", "minimum": 2, "maximum": 4 },
|
||||
"timebox_h": { "enum": [40, 80], "description": "Derivado de regras-sd.json por tipo × item" },
|
||||
"ust": { "type": "integer", "description": "timebox_h × semanas — RECALCULADO PELO APP" },
|
||||
"periodo": {
|
||||
"type": "object",
|
||||
"required": ["inicio", "fim"],
|
||||
"properties": {
|
||||
"inicio": { "type": "string", "format": "date" },
|
||||
"fim": { "type": "string", "format": "date" }
|
||||
}
|
||||
},
|
||||
"depende_de": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "n dos entregáveis dos quais este depende (encadeamento → períodos não podem sobrepor)"
|
||||
},
|
||||
"paralelo_com": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "n dos entregáveis paralelos (exige nota_paralelismo e times distintos)"
|
||||
},
|
||||
"nota_paralelismo": { "type": "string", "description": "Obrigatória quando paralelo_com não é vazio" },
|
||||
"resumo": { "type": "string", "description": "Três linhas: o que o entregável fez e o que produziu" },
|
||||
"atividades": {
|
||||
"type": "array",
|
||||
"minItems": 6,
|
||||
"maxItems": 9,
|
||||
"items": { "type": "string" },
|
||||
"description": "Blocos de trabalho concretos, explicáveis em uma frase"
|
||||
},
|
||||
"artefatos": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["nome", "confirmado_pelo_usuario"],
|
||||
"properties": {
|
||||
"nome": { "type": "string", "description": "Nome real do documento" },
|
||||
"data": { "type": "string", "format": "date", "description": "Quando houver — deve ser <= periodo.fim do entregável" },
|
||||
"confirmado_pelo_usuario": {
|
||||
"type": "boolean",
|
||||
"description": "TRAVA DE INTEGRIDADE: o LLM sugere TIPOS de artefato; nomes concretos só entram informados/confirmados pelo usuário. false = pendência bloqueante para exportação."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"criterios_aceite": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": { "type": "string" },
|
||||
"description": "Condições verificáveis por terceiro olhando o artefato — nunca 'funcionando adequadamente', 'bem construído', 'documentação completa'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"subtotais_por_item": {
|
||||
"type": "array",
|
||||
"description": "Obrigatório quando itens_tr tem mais de um item — é assim que se controla saldo de pool. CALCULADO PELO APP.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["item_tr", "ust"],
|
||||
"properties": {
|
||||
"item_tr": { "enum": ["I-02", "I-03", "I-04", "I-05"] },
|
||||
"ust": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"enquadramento_tr": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Uma entrada por item cruzado (Seção 5 repetida por item)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["item_tr", "titulo_literal", "descricao_item", "aderencia", "entregaveis_n"],
|
||||
"properties": {
|
||||
"item_tr": { "enum": ["I-02", "I-03", "I-04", "I-05"] },
|
||||
"titulo_literal": { "type": "string", "description": "Exatamente como em regras-sd.json / TR" },
|
||||
"descricao_item": { "type": "string", "description": "Transcrição do escopo do item conforme o TR" },
|
||||
"aderencia": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 5,
|
||||
"items": { "type": "string" },
|
||||
"description": "Um bullet por componente ENTREGUE, no vocabulário do item. Não citar componente que a SD não tem."
|
||||
},
|
||||
"entregaveis_n": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Quais entregáveis do quadro pertencem a este item"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"validacao": {
|
||||
"type": "array",
|
||||
"description": "Resultado do checklist (IDs de regras-sd.json). Itens 'codigo' são preenchidos pelo validador do app; itens 'julgamento' pela passada de revisão do LLM; itens 'usuario' pela confirmação explícita.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "status"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Ex.: CAL-02" },
|
||||
"status": { "enum": ["ok", "alerta", "falha", "pendente"] },
|
||||
"observacao": { "type": "string", "description": "Obrigatória quando status != ok" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"pendencias": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "O que ainda precisa de decisão humana antes do envio (ex.: repactuação de prazo, confirmação de artefato)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user