codetac 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +60 -0
- package/readme.md +172 -0
- package/src/action-view.mjs +104 -0
- package/src/ai.mjs +504 -0
- package/src/boundaries.mjs +483 -0
- package/src/browser/bar.js +593 -0
- package/src/cli.mjs +556 -0
- package/src/detect.mjs +198 -0
- package/src/diagnose.mjs +121 -0
- package/src/digest.mjs +317 -0
- package/src/home.mjs +15 -0
- package/src/origins.mjs +182 -0
- package/src/page.mjs +220 -0
- package/src/panel.mjs +800 -0
- package/src/recording.mjs +124 -0
- package/src/redact.mjs +35 -0
- package/src/register.mjs +94 -0
- package/src/runtime.mjs +353 -0
- package/src/sentences.mjs +209 -0
- package/src/store.mjs +376 -0
- package/src/transform.mjs +255 -0
package/src/ai.mjs
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Camada de IA (Fase 3): frases de finalidade redigidas por um modelo a partir
|
|
2
|
+
// do código real e dos factos gravados. Configuração própria do CodeTAC:
|
|
3
|
+
// CODETAC_AI_PROVIDER auto (omissão) | nenhum | ollama | anthropic | openai | compativel
|
|
4
|
+
// CODETAC_AI_MODEL modelo; no modo auto escolhe um modelo local do Ollama
|
|
5
|
+
// CODETAC_AI_KEY chave (Anthropic, OpenAI ou compatível)
|
|
6
|
+
// CODETAC_AI_URL endereço do Ollama ou da API compatível
|
|
7
|
+
// CODETAC_AI_CONFIG ficheiro JSON com os mesmos campos (omissão: ia.json na pasta de dados do CodeTAC: ~/.codetac, ou .codetac/ num checkout)
|
|
8
|
+
// CODETAC_LANG idioma das explicações (pt-PT por omissão, ou en)
|
|
9
|
+
// Sem fornecedor, ficam as frases fixas. Tudo o que é enviado passa antes pela
|
|
10
|
+
// redação de segredos; cada frase recebida é validada contra os factos.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createRedactor } from './redact.mjs';
|
|
15
|
+
import { language } from './sentences.mjs';
|
|
16
|
+
|
|
17
|
+
const PROMPT_VERSION = 6;
|
|
18
|
+
const OLLAMA_URL = 'http://127.0.0.1:11434';
|
|
19
|
+
// Local chat models preferred in auto mode, best first.
|
|
20
|
+
const OLLAMA_PREFERRED = [/^qwen2\.5-coder:14b/, /^qwen3:14b/, /^qwen3:8b/, /^qwen2\.5-coder:7b/, /^llama3\.[1-3]:8b/, /^gemma3/, /^mistral/];
|
|
21
|
+
const NOT_CHAT = /embed|bge|nomic|llava|vision|-vl|vl:|minicpm-v|smollm2:135m|:cloud|-cloud/;
|
|
22
|
+
const ANTHROPIC_MODEL = 'claude-opus-5';
|
|
23
|
+
|
|
24
|
+
export async function loadConfig({ env = process.env, directory } = {}) {
|
|
25
|
+
let file = {};
|
|
26
|
+
const path = env.CODETAC_AI_CONFIG || (directory ? join(directory, 'ia.json') : null);
|
|
27
|
+
if (path && existsSync(path)) {
|
|
28
|
+
try { file = JSON.parse(readFileSync(path, 'utf8')); } catch { file = {}; }
|
|
29
|
+
}
|
|
30
|
+
const pick = (name, key) => env[name] || file[key] || null;
|
|
31
|
+
const provider = String(pick('CODETAC_AI_PROVIDER', 'provider') ?? 'auto').toLowerCase();
|
|
32
|
+
const config = { provider, model: pick('CODETAC_AI_MODEL', 'model'), key: pick('CODETAC_AI_KEY', 'key'),
|
|
33
|
+
url: pick('CODETAC_AI_URL', 'url'), lang: language(pick('CODETAC_LANG', 'lang') ?? undefined) };
|
|
34
|
+
if (provider === 'nenhum' || provider === 'none') return { ...config, provider: null };
|
|
35
|
+
if (provider === 'auto' || provider === 'ollama') {
|
|
36
|
+
const url = config.url || OLLAMA_URL;
|
|
37
|
+
const models = await ollamaModels(url);
|
|
38
|
+
if (!models) return provider === 'ollama' ? { ...config, provider: null, problem: `Ollama não responde em ${url}.` } : { ...config, provider: null };
|
|
39
|
+
const chat = models.filter(name => !NOT_CHAT.test(name));
|
|
40
|
+
const model = config.model || OLLAMA_PREFERRED.map(pattern => chat.find(name => pattern.test(name))).find(Boolean) || chat[0];
|
|
41
|
+
if (!model) return { ...config, provider: null, problem: 'O Ollama não tem nenhum modelo de conversa instalado.' };
|
|
42
|
+
return { ...config, provider: 'ollama', url, model, local: /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(url) };
|
|
43
|
+
}
|
|
44
|
+
if (provider === 'anthropic') {
|
|
45
|
+
if (!config.key) return { ...config, provider: null, problem: 'Falta a chave da Anthropic (CODETAC_AI_KEY).' };
|
|
46
|
+
return { ...config, model: config.model || ANTHROPIC_MODEL, local: false };
|
|
47
|
+
}
|
|
48
|
+
if (provider === 'openai' || provider === 'compativel') {
|
|
49
|
+
if (!config.model) return { ...config, provider: null, problem: 'Falta o modelo (CODETAC_AI_MODEL).' };
|
|
50
|
+
const url = config.url || (provider === 'openai' ? 'https://api.openai.com' : null);
|
|
51
|
+
if (!url) return { ...config, provider: null, problem: 'Falta o endereço da API compatível (CODETAC_AI_URL).' };
|
|
52
|
+
if (provider === 'openai' && !config.key) return { ...config, provider: null, problem: 'Falta a chave da OpenAI (CODETAC_AI_KEY).' };
|
|
53
|
+
return { ...config, url, local: /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(url) };
|
|
54
|
+
}
|
|
55
|
+
return { ...config, provider: null, problem: `Fornecedor desconhecido: ${provider}.` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function ollamaModels(url) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(new URL('/api/tags', url), { signal: AbortSignal.timeout(800) });
|
|
61
|
+
if (!response.ok) return null;
|
|
62
|
+
return (await response.json()).models?.map(model => model.name) ?? [];
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What the panel shows about the configuration (never the key).
|
|
67
|
+
export function describeConfig(config) {
|
|
68
|
+
if (!config?.provider) return { active: false, problem: config?.problem ?? null, lang: config?.lang ?? 'pt-PT' };
|
|
69
|
+
return { active: true, provider: config.provider, model: config.model, local: Boolean(config.local), lang: config.lang };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Prompt and schema
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const SCHEMA = {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
finalidade: { type: 'string' },
|
|
80
|
+
fronteiras: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, finalidade: { type: 'string' } },
|
|
81
|
+
required: ['id', 'finalidade'], additionalProperties: false } },
|
|
82
|
+
},
|
|
83
|
+
required: ['finalidade', 'fronteiras'],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
};
|
|
86
|
+
const ACTION_SCHEMA = { type: 'object', properties: { finalidade: { type: 'string' } }, required: ['finalidade'], additionalProperties: false };
|
|
87
|
+
|
|
88
|
+
const GLOSSARY = 'Vocabulário de Portugal: base de dados (não banco de dados), pedido (não requisição), utilizador (não usuário), ficheiro (não arquivo), devolve (não retorna), procura (não busca), ecrã (não tela), sessão, palavra-passe (não senha).';
|
|
89
|
+
function instructions(lang) {
|
|
90
|
+
const idiom = lang === 'en' ? 'English' : 'português de Portugal (não do Brasil)';
|
|
91
|
+
return [
|
|
92
|
+
...(lang === 'en' ? [] : [GLOSSARY]),
|
|
93
|
+
`Escreves frases de finalidade para quem não lê código com facilidade, em ${idiom}.`,
|
|
94
|
+
'Recebes o código de uma função e os factos observados quando ela correu.',
|
|
95
|
+
'Diz para que serve o passo, numa frase curta (no máximo 15 palavras), sem jargão de programação.',
|
|
96
|
+
'Regras obrigatórias:',
|
|
97
|
+
'- Não afirmes efeitos (gravar ou ler dados, emails, pagamentos, ficheiros, chamadas a serviços externos ou a IA) que não estejam nos factos observados, mesmo que o código os pudesse fazer.',
|
|
98
|
+
'- Não menciones tabelas, serviços, domínios ou nomes que não apareçam nos factos ou no código.',
|
|
99
|
+
'- Não inventes a razão de negócio: se o código não a mostra, descreve só o que o passo faz.',
|
|
100
|
+
'- Descreve só o que aconteceu nesta execução. Não descrevas ramos alternativos do código («se não…», «caso contrário…»).',
|
|
101
|
+
'- Não repitas números dos factos; eles já são mostrados ao lado.',
|
|
102
|
+
'Responde só com JSON no formato pedido. Para cada fronteira indicada, dá uma frase curta que explique para que serve nesse passo.',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
function actionInstructions(lang) {
|
|
106
|
+
const idiom = lang === 'en' ? 'English' : 'português de Portugal (não do Brasil)';
|
|
107
|
+
return [
|
|
108
|
+
`Resumes numa frase (no máximo 25 palavras), em ${idiom}, o que uma ação do utilizador numa aplicação faz, para quem não lê código.`,
|
|
109
|
+
...(lang === 'en' ? [] : [GLOSSARY]),
|
|
110
|
+
'Usa só os factos dados: o elemento carregado, os pedidos, as finalidades dos passos e os efeitos observados.',
|
|
111
|
+
'Não afirmes efeitos que não estejam nos efeitos observados. Não inventes a razão de negócio. Responde só com JSON.',
|
|
112
|
+
].join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Providers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function complete(config, system, user, schema) {
|
|
120
|
+
const signal = AbortSignal.timeout(config.provider === 'ollama' ? 300_000 : 60_000);
|
|
121
|
+
if (config.provider === 'ollama') {
|
|
122
|
+
const response = await fetch(new URL('/api/chat', config.url), {
|
|
123
|
+
method: 'POST', signal, headers: { 'content-type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ model: config.model, stream: false, think: false, format: schema, options: { temperature: 0 },
|
|
125
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok) throw new Error(`Ollama: estado ${response.status}`);
|
|
128
|
+
return JSON.parse((await response.json()).message?.content ?? '');
|
|
129
|
+
}
|
|
130
|
+
if (config.provider === 'anthropic') {
|
|
131
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
132
|
+
const client = new Anthropic({ apiKey: config.key, maxRetries: 2 });
|
|
133
|
+
const response = await client.beta.messages.create({
|
|
134
|
+
model: config.model, max_tokens: 2000, betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default',
|
|
135
|
+
output_config: { effort: 'low', format: { type: 'json_schema', schema } },
|
|
136
|
+
system, messages: [{ role: 'user', content: user }],
|
|
137
|
+
}, { signal });
|
|
138
|
+
if (response.stop_reason === 'refusal') throw new Error('o modelo recusou o pedido');
|
|
139
|
+
const text = response.content.find(block => block.type === 'text')?.text;
|
|
140
|
+
return JSON.parse(text ?? '');
|
|
141
|
+
}
|
|
142
|
+
// OpenAI or any compatible API.
|
|
143
|
+
const response = await fetch(new URL('/v1/chat/completions', config.url), {
|
|
144
|
+
method: 'POST', signal,
|
|
145
|
+
headers: { 'content-type': 'application/json', ...(config.key ? { authorization: `Bearer ${config.key}` } : {}) },
|
|
146
|
+
body: JSON.stringify({ model: config.model,
|
|
147
|
+
response_format: { type: 'json_schema', json_schema: { name: 'finalidade', strict: true, schema } },
|
|
148
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) throw new Error(`${config.provider}: estado ${response.status}`);
|
|
151
|
+
return JSON.parse((await response.json()).choices?.[0]?.message?.content ?? '');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Facts, validation
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function walk(nodes, visit, parent = null) {
|
|
159
|
+
for (const node of nodes) {
|
|
160
|
+
visit(node, parent);
|
|
161
|
+
walk(node.children ?? [], visit, node);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function subtree(node) {
|
|
165
|
+
const boundaries = [];
|
|
166
|
+
const functions = [];
|
|
167
|
+
walk(node.children ?? [], item => {
|
|
168
|
+
if (item.type === 'boundary') boundaries.push(item);
|
|
169
|
+
else if (item.type === 'function') functions.push(item);
|
|
170
|
+
});
|
|
171
|
+
return { boundaries, functions };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CLAIMS = [
|
|
175
|
+
// "the user's email" is data; sending one is the effect.
|
|
176
|
+
{ kinds: ['email', 'mensagem'], pattern: /\b(envi\w*|mand\w*|send\w*|dispar\w*)\b[^.;]{0,40}\b(e-?mails?|correio|mensage(m|ns)|sms|messages?|link)\b/i },
|
|
177
|
+
{ kinds: ['pagamento'], pattern: /\b(pagamentos?|payments?|cobran[çc]as?|charges?|stripe)\b/i },
|
|
178
|
+
{ kinds: ['ia'], pattern: /\b(IA|AI|LLM|OpenAI|Anthropic|Claude|GPT|intelig[êe]ncia artificial|language model|modelo de (linguagem|IA))\b/ },
|
|
179
|
+
{ kinds: ['ficheiros'], pattern: /\b(ficheiros?|arquivos?|files?|uploads?|S3|buckets?|armazenamento de ficheiros)\b/i },
|
|
180
|
+
{ kinds: ['base-de-dados'], pattern: /\b(base de dados|bases de dados|banco de dados|database|tabelas?|tables?|sql)\b/i },
|
|
181
|
+
{ kinds: ['http', 'ia', 'pagamento', 'email', 'mensagem', 'autenticação', 'ficheiros', 'base-de-dados'],
|
|
182
|
+
pattern: /\b(servi[çc]o externo|API externa|external (service|API)|third-party)\b/i },
|
|
183
|
+
];
|
|
184
|
+
const WORD = /[\p{L}\p{N}_$.-]+/gu;
|
|
185
|
+
// "sem efeitos na base de dados", "não envia emails": a negation is not a claim.
|
|
186
|
+
function negated(text, index) {
|
|
187
|
+
return /(^|[^\p{L}])(sem|não|nenhum\w*|nem|without|no|not|never|nunca)([^\p{L}][^.;]{0,30})?$/iu.test(text.slice(Math.max(0, index - 40), index));
|
|
188
|
+
}
|
|
189
|
+
const CONDITIONAL = /\b(caso contr[áa]rio|sen[ãa]o|se n[ãa]o (for|corresponder|coincidir|houver)|otherwise|if not|or else)\b/i;
|
|
190
|
+
const WRITE_VERBS = /\b(grava|gravar|guarda|guardar|regista|registar|insere|inserir|armazena|armazenar|persiste|atualiza|actualiza|apaga|elimina|saves?|stores?|persists?|inserts?|writes?|updates?|deletes?)\b/i;
|
|
191
|
+
const NOT_STORAGE = /\b(cookies?|browser|navegador|mem[óo]ria|memory|cache|vari[áa]ve(l|is)|variables?)\b/i;
|
|
192
|
+
// Tables named in SQL text inside the code: mentioning one that did not run is a claim.
|
|
193
|
+
export function tablesInCode(code) {
|
|
194
|
+
return [...String(code ?? '').matchAll(/\b(?:from|into|update|join|table(?: if (?:not )?exists)?)\s+[`"[]?([A-Za-z_][\w]*)/gi)]
|
|
195
|
+
.map(match => match[1].toLowerCase()).filter(name => !/^(select|if|not|exists|set|the|a|o)$/.test(name));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A sentence may not claim what was not observed. Returns null when valid,
|
|
199
|
+
// or the reason it was rejected.
|
|
200
|
+
export function validateSentence(text, { boundaries, allowedWords, knownTables, knownHosts, maxLength = 280, conditionals = false }) {
|
|
201
|
+
if (typeof text !== 'string' || !text.trim()) return 'frase vazia';
|
|
202
|
+
if (text.length > maxLength) return 'frase demasiado longa';
|
|
203
|
+
const branch = !conditionals && text.match(CONDITIONAL);
|
|
204
|
+
if (branch) return `descreve um ramo que pode não ter corrido («${branch[0]}»)`;
|
|
205
|
+
const kinds = new Set(boundaries.map(item => item.kind));
|
|
206
|
+
// Writing verbs need an observed write (database or file), unless the
|
|
207
|
+
// sentence is about the browser's cookies or memory.
|
|
208
|
+
const writes = boundaries.some(item => (item.kind === 'base-de-dados' && /^(INSERT|UPDATE|DELETE|UPSERT|REPLACE|MERGE|CREATE|ALTER|DROP)$/.test(item.operation ?? ''))
|
|
209
|
+
|| (item.kind === 'ficheiros' && !['leitura', 'verificação'].includes(item.operation)) || ['email', 'mensagem', 'pagamento'].includes(item.kind));
|
|
210
|
+
const verb = text.match(WRITE_VERBS);
|
|
211
|
+
if (verb && !writes && !NOT_STORAGE.test(text) && !negated(text, verb.index)) return `diz «${verb[0]}» sem nenhuma escrita observada`;
|
|
212
|
+
for (const claim of CLAIMS) {
|
|
213
|
+
const match = text.match(claim.pattern);
|
|
214
|
+
if (match && !claim.kinds.some(kind => kinds.has(kind)) && !negated(text, match.index)) return `menciona «${match[0]}» sem essa fronteira nos factos`;
|
|
215
|
+
}
|
|
216
|
+
const tables = new Set(boundaries.flatMap(item => item.tables ?? []).map(name => name.toLowerCase()));
|
|
217
|
+
const hosts = new Set(boundaries.map(item => item.host).filter(Boolean).map(name => name.toLowerCase()));
|
|
218
|
+
for (const raw of text.match(WORD) ?? []) {
|
|
219
|
+
const word = raw.replace(/^[.-]+|[.-]+$/g, '');
|
|
220
|
+
const lower = word.toLowerCase();
|
|
221
|
+
// A table named like an ordinary word ("item", "user") only counts when
|
|
222
|
+
// the sentence presents it as a table or quotes it.
|
|
223
|
+
const asTable = /[_\d]/.test(word) || new RegExp(`(tabelas?|tables?)\\s+[\\'"«\`]?${lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b|[\\'"«\`]${lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\'"»\`]`, 'i').test(text);
|
|
224
|
+
if (knownTables.has(lower) && !tables.has(lower) && asTable) return `menciona a tabela ${word}, que este passo não usou`;
|
|
225
|
+
if (knownHosts.has(lower) && !hosts.has(lower)) return `menciona ${word}, que este passo não chamou`;
|
|
226
|
+
// Identifier-like words (snake_case, camelCase, dotted names, `code`) must come from the facts or the code.
|
|
227
|
+
const identifier = /[_$]/.test(word) || /[a-z][A-Z]/.test(word) || /\w\.\w/.test(word);
|
|
228
|
+
if (identifier && !allowedWords.has(lower)) return `menciona ${word}, que não está nos factos nem no código`;
|
|
229
|
+
}
|
|
230
|
+
const quoted = [...text.matchAll(/`([^`]+)`/g)].map(match => match[1].toLowerCase());
|
|
231
|
+
for (const item of quoted) if (!allowedWords.has(item)) return `menciona ${item}, que não está nos factos nem no código`;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Models often answer in Brazilian Portuguese despite the instruction; the
|
|
236
|
+
// most common forms are replaced (word by word, meaning unchanged).
|
|
237
|
+
const ARTICLES = { o: 'a', os: 'as', no: 'na', nos: 'nas', do: 'da', dos: 'das', ao: 'à', aos: 'às', um: 'uma', pelo: 'pela', a: 'o', as: 'os', na: 'no', nas: 'nos', da: 'do', das: 'dos', 'à': 'ao', 'às': 'aos', uma: 'um', pela: 'pelo' };
|
|
238
|
+
const swap = article => { const out = ARTICLES[article.toLowerCase()] ?? article; return article[0] === article[0].toUpperCase() ? out[0].toUpperCase() + out.slice(1) : out; };
|
|
239
|
+
const PT_PT = [
|
|
240
|
+
// Gender changes: banco (m) → base (f); requisição (f) → pedido (m); tela (f) → ecrã (m).
|
|
241
|
+
[/(?<!\p{L})(o|os|no|nos|do|dos|ao|aos|um|pelo) (banco|bancos) de dados(?!\p{L})/giu, (m, a, b) => `${swap(a)} base${b.endsWith('s') ? 's' : ''} de dados`],
|
|
242
|
+
[/(?<!\p{L})(a|as|na|nas|da|das|à|às|uma|pela) (requisição|requisições)(?!\p{L})/giu, (m, a, r) => `${swap(a)} ${r.endsWith('ões') ? 'pedidos' : 'pedido'}`],
|
|
243
|
+
[/(?<!\p{L})(a|as|na|nas|da|das|à|às|uma|pela) (tela|telas)(?!\p{L})/giu, (m, a, t) => `${swap(a)} ${t.endsWith('s') ? 'ecrãs' : 'ecrã'}`],
|
|
244
|
+
[/\bbanco(s)? de dados\b/gi, 'base$1 de dados'], [/\brequisiç(ão|ões)\b/gi, (m, e) => e === 'ão' ? 'pedido' : 'pedidos'],
|
|
245
|
+
[/\busuário(s)?\b/gi, 'utilizador$1'], [/\barquivo(s)?\b/gi, 'ficheiro$1'], [/\bretorna(m|r)?\b/gi, 'devolve$1'], [/\bbusca(m|r)?\b/gi, 'procura$1'],
|
|
246
|
+
[/\btela(s)?\b/gi, 'ecrã$1'], [/\bregistra(r|m)?\b/gi, 'regista$1'], [/\bexclu[íi]d(o|a|os|as)\b/gi, 'eliminad$1'],
|
|
247
|
+
[/\bexcluir\b/gi, 'eliminar'], [/\bexclui\b/gi, 'elimina'], [/\bsenha(s)?\b/gi, 'palavra$1-passe'], [/\bstatus\b/gi, 'estado']];
|
|
248
|
+
export function portuguese(text, lang) {
|
|
249
|
+
if (lang === 'en' || typeof text !== 'string') return text;
|
|
250
|
+
let out = text;
|
|
251
|
+
for (const [pattern, replacement] of PT_PT) out = out.replace(pattern, (...args) => {
|
|
252
|
+
const value = typeof replacement === 'function' ? replacement(...args) : args[0].replace(pattern, replacement);
|
|
253
|
+
return args[0][0] === args[0][0].toUpperCase() ? value[0].toUpperCase() + value.slice(1) : value;
|
|
254
|
+
});
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function wordsOf(...texts) {
|
|
259
|
+
const words = new Set();
|
|
260
|
+
for (const text of texts) for (const raw of String(text ?? '').match(WORD) ?? []) {
|
|
261
|
+
const word = raw.replace(/^[.-]+|[.-]+$/g, '').toLowerCase();
|
|
262
|
+
words.add(word);
|
|
263
|
+
for (const part of word.split('.')) words.add(part);
|
|
264
|
+
}
|
|
265
|
+
return words;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Generation
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Free questions about one step (Phase 4)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const ANSWER_SCHEMA = { type: 'object', properties: { sabe: { type: 'boolean' }, resposta: { type: 'string' } },
|
|
277
|
+
required: ['sabe', 'resposta'], additionalProperties: false };
|
|
278
|
+
function questionInstructions(lang) {
|
|
279
|
+
const idiom = lang === 'en' ? 'English' : 'português de Portugal (não do Brasil)';
|
|
280
|
+
return [
|
|
281
|
+
`Respondes, em ${idiom}, a uma pergunta de uma pessoa que não lê código com facilidade sobre um passo de uma aplicação.`,
|
|
282
|
+
...(lang === 'en' ? [] : [GLOSSARY]),
|
|
283
|
+
'Usa só os factos dados: o código do passo, o que foi observado quando correu e, se existirem, os valores gravados e as linhas executadas.',
|
|
284
|
+
'Se os factos não chegam para responder, põe "sabe": false e diz em «resposta» o que falta saber, sem supor.',
|
|
285
|
+
'Não afirmes efeitos (gravar dados, emails, pagamentos, ficheiros, serviços externos) que não estejam nos factos observados.',
|
|
286
|
+
'Distingue o que aconteceu nesta execução do que o código faria noutros casos; se falares de outros casos, di-lo explicitamente.',
|
|
287
|
+
'Responde em no máximo 120 palavras, sem jargão. Responde só com JSON.',
|
|
288
|
+
].join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// One step of a request dossier (with its digest) and a question. Values
|
|
292
|
+
// and executed lines go to the model only when it runs on this machine.
|
|
293
|
+
export async function answerQuestion({ config, dossier, stepId, question, readCode, redact = createRedactor() }) {
|
|
294
|
+
if (!config?.provider) return { available: false, text: 'As perguntas precisam de um modelo de IA configurado (ver «Frases de finalidade e modelo de IA» no README).' };
|
|
295
|
+
let step = null;
|
|
296
|
+
let parent = null;
|
|
297
|
+
walk(dossier.digest.nodes, (node, holder) => { if (node.id === stepId) { step = node; parent = holder; } });
|
|
298
|
+
if (!step) return { available: true, known: false, text: 'Este passo não foi encontrado no dossiê.' };
|
|
299
|
+
const target = step.type === 'function' ? step : parent?.type === 'function' ? parent : null;
|
|
300
|
+
const code = target ? readCode(dossier.request.run, target.file, target.line, target.endLine) : null;
|
|
301
|
+
const facts = target ? subtree(target) : { boundaries: [step], functions: [] };
|
|
302
|
+
const withValues = Boolean(config.local);
|
|
303
|
+
const lines = [
|
|
304
|
+
`Pedido: ${dossier.request.method} ${dossier.request.path} (estado ${dossier.request.status ?? 'desconhecido'}).`,
|
|
305
|
+
step.type === 'function' ? `Passo: função ${step.function} (${step.purpose.text}).` : `Passo: fronteira — ${step.purpose.text}${step.sql ? ` — SQL: ${step.sql}` : ''}${target ? `, dentro da função ${target.function}` : ''}.`,
|
|
306
|
+
step.error ? 'O passo terminou com erro.' : '',
|
|
307
|
+
facts.functions.length ? `Funções do projeto chamadas dentro dele: ${[...new Set(facts.functions.map(item => item.function))].slice(0, 30).join(', ')}.` : '',
|
|
308
|
+
facts.boundaries.length ? `Fronteiras observadas:\n${facts.boundaries.slice(0, 40).map(item => `- ${item.purpose.text}${item.sql ? ` — SQL: ${item.sql.slice(0, 300)}` : ''}`).join('\n')}` : 'Nenhuma fronteira observada neste passo.',
|
|
309
|
+
];
|
|
310
|
+
const detail = target?.detail;
|
|
311
|
+
if (detail && withValues) {
|
|
312
|
+
lines.push(`Valores gravados nesta chamada: argumentos ${JSON.stringify(detail.args).slice(0, 4000)}; ${'returned' in detail ? `devolveu ${JSON.stringify(detail.returned).slice(0, 4000)}` : `lançou ${JSON.stringify(detail.threw)}`}.`);
|
|
313
|
+
lines.push(`Linhas executadas (do ficheiro original): ${detail.lines.join(', ') || 'nenhuma registada'}.`);
|
|
314
|
+
} else if (detail) {
|
|
315
|
+
lines.push(`Linhas executadas (do ficheiro original): ${detail.lines.join(', ') || 'nenhuma registada'}. Os valores gravados não são enviados a modelos fora desta máquina.`);
|
|
316
|
+
} else {
|
|
317
|
+
lines.push('Não há valores gravados para este passo (o detalhe não foi pedido).');
|
|
318
|
+
}
|
|
319
|
+
if (code) lines.push('', `Código (${target.function}, a partir da linha ${code.start}):`, redact(code.lines.join('\n')).slice(0, config.local ? 6000 : 12000));
|
|
320
|
+
lines.push('', `Pergunta: ${String(question).slice(0, 1000)}`);
|
|
321
|
+
const result = await complete(config, questionInstructions(config.lang), redact(lines.filter(line => line !== '').join('\n')), ANSWER_SCHEMA);
|
|
322
|
+
const text = portuguese(String(result?.resposta ?? ''), config.lang);
|
|
323
|
+
const knownTables = new Set([...dossier.digest.nodes.flatMap(node => { const all = []; walk([node], item => all.push(...(item.tables ?? []))); return all; }), ...tablesInCode(code?.lines.join('\n'))].map(name => name.toLowerCase()));
|
|
324
|
+
const knownHosts = new Set();
|
|
325
|
+
walk(dossier.digest.nodes, item => { if (item.host) knownHosts.add(item.host.toLowerCase()); });
|
|
326
|
+
const allowedWords = wordsOf(code?.lines.join('\n'), question, ...lines);
|
|
327
|
+
// Answers may talk about other cases of the code, so conditionals are allowed here.
|
|
328
|
+
const rejected = validateSentence(text, { boundaries: facts.boundaries, allowedWords, knownTables, knownHosts, maxLength: 1500, conditionals: true });
|
|
329
|
+
return { available: true, known: Boolean(result?.sabe), text, rejected, model: config.model, local: Boolean(config.local), valuesSent: Boolean(detail && withValues) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function createPurposes({ config, cache, readCode, redact = createRedactor(), limit = Number(process.env.CODETAC_AI_LIMIT || 40) }) {
|
|
333
|
+
const jobs = new Map();
|
|
334
|
+
|
|
335
|
+
function key(...parts) {
|
|
336
|
+
return createHash('sha256').update(JSON.stringify([PROMPT_VERSION, config.provider, config.model, config.lang, ...parts])).digest('hex');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// One request per project function: its code, what it called and the
|
|
340
|
+
// boundaries directly inside it.
|
|
341
|
+
function tasksOf(dossiers) {
|
|
342
|
+
const tasks = [];
|
|
343
|
+
const knownTables = new Set();
|
|
344
|
+
const knownHosts = new Set();
|
|
345
|
+
for (const dossier of dossiers) {
|
|
346
|
+
walk(dossier.digest.nodes, node => {
|
|
347
|
+
for (const table of node.tables ?? []) knownTables.add(table.toLowerCase());
|
|
348
|
+
if (node.host) knownHosts.add(node.host.toLowerCase());
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// Steps shown in the grouped view first (breadth first), then the rest;
|
|
352
|
+
// the same function with the same facts is asked once.
|
|
353
|
+
const candidates = [];
|
|
354
|
+
for (const dossier of dossiers) {
|
|
355
|
+
const queue = dossier.digest.nodes.map(node => ({ node, parent: null, shown: true, depth: 0 }));
|
|
356
|
+
while (queue.length) {
|
|
357
|
+
const { node, parent, shown, depth } = queue.shift();
|
|
358
|
+
if (node.type === 'function' && parent?.type !== 'group') {
|
|
359
|
+
const leaf = !node.children.length && !node.error && depth > 0;
|
|
360
|
+
candidates.push({ node, dossier, shown: shown && !leaf, depth });
|
|
361
|
+
}
|
|
362
|
+
const open = node.type !== 'group' && busy(node);
|
|
363
|
+
for (const child of node.children ?? []) queue.push({ node: child, parent: node, shown: shown && open, depth: depth + 1 });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
candidates.sort((a, b) => Number(b.shown) - Number(a.shown));
|
|
367
|
+
const unique = new Map();
|
|
368
|
+
const codes = new Map();
|
|
369
|
+
for (const { node, dossier } of candidates) {
|
|
370
|
+
const where = `${dossier.request.run}\n${node.file}\n${node.line}\n${node.endLine}`;
|
|
371
|
+
if (!codes.has(where)) codes.set(where, readCode(dossier.request.run, node.file, node.line, node.endLine));
|
|
372
|
+
const code = codes.get(where);
|
|
373
|
+
if (!code) continue;
|
|
374
|
+
const facts = subtree(node);
|
|
375
|
+
const direct = node.children.filter(item => item.type === 'boundary');
|
|
376
|
+
const task = { node, dossier, code: redact(code.lines.join('\n')).slice(0, config.local ? 6_000 : 12_000), facts, direct, knownTables, knownHosts, same: [] };
|
|
377
|
+
const signature = taskKey(task);
|
|
378
|
+
if (unique.has(signature)) unique.get(signature).same.push(task);
|
|
379
|
+
else if (unique.size < limit) unique.set(signature, Object.assign(task, { signature }));
|
|
380
|
+
}
|
|
381
|
+
return [...unique.values()];
|
|
382
|
+
}
|
|
383
|
+
function busy(node) { return (node.children ?? []).some(child => child.type === 'boundary' || child.error || busy(child)); }
|
|
384
|
+
function taskKey(task) {
|
|
385
|
+
return key('function', [task.node.function, task.code, task.node.purpose.text.replace(/\d+/g, 'N'),
|
|
386
|
+
task.facts.boundaries.map(item => item.purpose.text.replace(/\d+/g, 'N')), task.direct.map(item => item.purpose.text.replace(/\d+/g, 'N'))]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function prompt(task) {
|
|
390
|
+
const { node, facts, direct } = task;
|
|
391
|
+
const lines = [
|
|
392
|
+
`Função: ${node.function} (${task.code ? 'código abaixo' : 'sem código'})`,
|
|
393
|
+
`Factos observados nesta execução: ${node.purpose.text}.`,
|
|
394
|
+
facts.functions.length ? `Funções do projeto chamadas (por ordem): ${[...new Set(facts.functions.map(item => item.function))].slice(0, 30).join(', ')}.` : 'Não chamou outras funções do projeto.',
|
|
395
|
+
facts.boundaries.length ? `Fronteiras observadas dentro desta função:\n${facts.boundaries.slice(0, 40).map(item => `- ${item.purpose.text.replace(/\d+/g, 'N')}${item.sql ? ` — SQL: ${item.sql.slice(0, 300)}` : ''}`).join('\n')}`
|
|
396
|
+
: 'Nenhuma fronteira observada (não leu nem gravou dados, não enviou emails, não chamou serviços externos).',
|
|
397
|
+
node.error ? 'A função terminou com erro.' : '',
|
|
398
|
+
direct.length ? `Fronteiras a explicar (id — facto):\n${direct.map((item, index) => `- b${index + 1} — ${item.purpose.text.replace(/\d+/g, 'N')}`).join('\n')}` : 'Não há fronteiras a explicar: devolve "fronteiras": [].',
|
|
399
|
+
'',
|
|
400
|
+
'Código:',
|
|
401
|
+
task.code,
|
|
402
|
+
];
|
|
403
|
+
return redact(lines.filter(line => line !== '').join('\n'));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function check(text, task, boundaries) {
|
|
407
|
+
const allowedWords = wordsOf(task.code, task.node.function, ...task.facts.functions.map(item => item.function),
|
|
408
|
+
...boundaries.flatMap(item => [...(item.tables ?? []), item.host, item.provider, item.sql]));
|
|
409
|
+
const knownTables = new Set([...task.knownTables, ...tablesInCode(task.code)]);
|
|
410
|
+
return validateSentence(text, { boundaries, allowedWords, knownTables, knownHosts: task.knownHosts });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function run(task) {
|
|
414
|
+
const cacheKey = task.signature;
|
|
415
|
+
let answer = cache.get(cacheKey);
|
|
416
|
+
if (!answer) {
|
|
417
|
+
const result = await complete(config, instructions(config.lang), prompt(task), SCHEMA);
|
|
418
|
+
answer = { finalidade: String(result?.finalidade ?? ''), fronteiras: (result?.fronteiras ?? []).map(item => ({ id: String(item.id), finalidade: String(item.finalidade ?? '') })) };
|
|
419
|
+
cache.set(cacheKey, answer);
|
|
420
|
+
}
|
|
421
|
+
const out = {};
|
|
422
|
+
answer = { finalidade: portuguese(answer.finalidade, config.lang), fronteiras: answer.fronteiras.map(item => ({ ...item, finalidade: portuguese(item.finalidade, config.lang) })) };
|
|
423
|
+
const rejected = check(answer.finalidade, task, task.facts.boundaries);
|
|
424
|
+
out[task.node.id] = rejected ? { source: 'factos', rejected, proposed: answer.finalidade } : { source: 'ia', text: answer.finalidade };
|
|
425
|
+
task.direct.forEach((boundary, index) => {
|
|
426
|
+
const proposal = (answer.fronteiras.find(item => item.id === boundary.id) ?? answer.fronteiras.find(item => item.id === `b${index + 1}`))?.finalidade;
|
|
427
|
+
if (!proposal) return;
|
|
428
|
+
const reason = check(proposal, task, [boundary]);
|
|
429
|
+
out[boundary.id] = reason ? { source: 'factos', rejected: reason, proposed: proposal } : { source: 'ia', text: proposal };
|
|
430
|
+
});
|
|
431
|
+
// Identical calls of the same function get the same sentences.
|
|
432
|
+
for (const other of task.same) {
|
|
433
|
+
out[other.node.id] = out[task.node.id];
|
|
434
|
+
other.direct.forEach((boundary, index) => { if (out[task.direct[index]?.id]) out[boundary.id] = out[task.direct[index].id]; });
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function actionSentence(action, dossiers, purposes) {
|
|
440
|
+
const steps = [];
|
|
441
|
+
for (const dossier of dossiers) walk(dossier.digest.nodes, (node, parent) => {
|
|
442
|
+
if (node.type === 'function' && parent?.type !== 'group' && steps.length < 30) steps.push(`${node.function}: ${purposes[node.id]?.text ?? node.purpose.text}`);
|
|
443
|
+
});
|
|
444
|
+
const facts = [
|
|
445
|
+
`Ação: ${action.digest.summary}.`,
|
|
446
|
+
steps.length ? `Passos do servidor:\n${steps.map(item => `- ${item}`).join('\n')}` : 'Não houve passos do projeto no servidor.',
|
|
447
|
+
`Efeitos observados: ${action.digest.effects.items.map(item => item.text).join('; ') || action.digest.effects.none}.`,
|
|
448
|
+
].join('\n');
|
|
449
|
+
const cacheKey = key('action', facts.replace(/\d+/g, 'N'));
|
|
450
|
+
let answer = cache.get(cacheKey);
|
|
451
|
+
if (!answer) {
|
|
452
|
+
answer = { finalidade: String((await complete(config, actionInstructions(config.lang), redact(facts), ACTION_SCHEMA))?.finalidade ?? '') };
|
|
453
|
+
cache.set(cacheKey, answer);
|
|
454
|
+
}
|
|
455
|
+
answer = { finalidade: portuguese(answer.finalidade, config.lang) };
|
|
456
|
+
const boundaries = dossiers.flatMap(dossier => { const all = []; walk(dossier.digest.nodes, node => { if (node.type === 'boundary') all.push(node); }); return all; });
|
|
457
|
+
const knownTables = new Set(boundaries.flatMap(item => item.tables ?? []).map(name => name.toLowerCase()));
|
|
458
|
+
const knownHosts = new Set(boundaries.map(item => item.host).filter(Boolean).map(name => name.toLowerCase()));
|
|
459
|
+
const allowedWords = wordsOf(facts, action.label, ...dossiers.flatMap(dossier => [dossier.request.path]));
|
|
460
|
+
const rejected = validateSentence(answer.finalidade, { boundaries, allowedWords, knownTables, knownHosts });
|
|
461
|
+
return rejected ? { source: 'factos', rejected, proposed: answer.finalidade } : { source: 'ia', text: answer.finalidade };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Starts (or continues) the generation for a dossier and returns what is
|
|
465
|
+
// ready. The work goes on in the background between calls.
|
|
466
|
+
function status(id, build) {
|
|
467
|
+
let job = jobs.get(id);
|
|
468
|
+
if (!job) {
|
|
469
|
+
job = { purposes: {}, done: 0, total: null, errors: [], finished: false };
|
|
470
|
+
jobs.set(id, job);
|
|
471
|
+
job.promise = (async () => {
|
|
472
|
+
try {
|
|
473
|
+
const { dossiers, action } = await build();
|
|
474
|
+
const tasks = tasksOf(dossiers);
|
|
475
|
+
job.total = tasks.length + (action ? 1 : 0);
|
|
476
|
+
const concurrency = config.provider === 'ollama' ? 1 : 4;
|
|
477
|
+
let next = 0;
|
|
478
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
|
479
|
+
while (next < tasks.length) {
|
|
480
|
+
const task = tasks[next++];
|
|
481
|
+
try { Object.assign(job.purposes, await run(task)); }
|
|
482
|
+
catch (error) { job.errors.push(`${task.node.function}: ${error.message}`); }
|
|
483
|
+
job.done++;
|
|
484
|
+
}
|
|
485
|
+
}));
|
|
486
|
+
if (action) {
|
|
487
|
+
try { job.purposes.action = await actionSentence(action, dossiers, job.purposes); }
|
|
488
|
+
catch (error) { job.errors.push(`ação: ${error.message}`); }
|
|
489
|
+
job.done++;
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
job.errors.push(error.message);
|
|
493
|
+
} finally {
|
|
494
|
+
job.finished = true;
|
|
495
|
+
// A finished job is kept a short while; the answers stay in the cache.
|
|
496
|
+
setTimeout(() => jobs.delete(id), 60_000).unref();
|
|
497
|
+
}
|
|
498
|
+
})();
|
|
499
|
+
}
|
|
500
|
+
return { purposes: job.purposes, done: job.done, total: job.total, finished: job.finished, errors: job.errors.slice(0, 5) };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { status, wait: id => jobs.get(id)?.promise };
|
|
504
|
+
}
|