feat: skill hub — distribuzione skill via Gitea, indicizzazione e ricerca
- extensions/skill-hub.ts: skill_search (FTS su frontmatter+body+references), skill_info, skill_sync/commando /skill-sync, iniezione istruzioni via before_agent_start (append al systemPrompt, mai replace) - skills/sap-timesheet: prima skill migrata (SAP CATS timesheet) - distribuzione: pi package (keyword pi-package), install git:git.enne2.net/enne2/pi-skill-hub
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* skill-hub.ts — Indicizzazione, ricerca e caricamento dinamico delle skill
|
||||
* condivise del package pi-skill-hub.
|
||||
*
|
||||
* - `skill_search` : ricerca testuale su frontmatter, corpo e references
|
||||
* - `skill_info` : dettaglio di una skill (frontmatter + body + references)
|
||||
* - `skill_sync` : ricostruisce l'indice (scan a mtime)
|
||||
* - `before_agent_start`: iniezione di istruzioni brevi nel system prompt
|
||||
*
|
||||
* Il routing primario resta quello nativo di pi (description nel frontmatter);
|
||||
* skill_search è il fallback per ricerca profonda o task non riconosciuti.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
||||
const MAX_CHARS_PER_SKILL = 200_000;
|
||||
const MAX_BODY_IN_RESULT = 12_000;
|
||||
|
||||
type SkillEntry = {
|
||||
name: string;
|
||||
dir: string;
|
||||
skillMdPath: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
body: string;
|
||||
references: { path: string; name: string; text: string }[];
|
||||
scripts: string[];
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
type IndexState = {
|
||||
entries: SkillEntry[];
|
||||
fingerprints: Map<string, number>;
|
||||
builtAt: number;
|
||||
};
|
||||
|
||||
const state: { index?: IndexState } = {};
|
||||
|
||||
// ---------------------------------------------------------------- utilities
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9àèéìòù#._-]+/i)
|
||||
.filter((t) => t.length > 1);
|
||||
}
|
||||
|
||||
/** Parser frontmatter tollerante: name, description (inline o folded '>'), metadata (tags). */
|
||||
function parseFrontmatter(raw: string): {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
body: string;
|
||||
} {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
const fm = m ? m[1] : "";
|
||||
const body = m ? raw.slice(m[0].length) : raw;
|
||||
|
||||
let name = "";
|
||||
let description = "";
|
||||
let descMode = false;
|
||||
let inMetadata = false;
|
||||
const tags: string[] = [];
|
||||
|
||||
for (const line of fm.split(/\r?\n/)) {
|
||||
const top = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (top) {
|
||||
inMetadata = top[1] === "metadata";
|
||||
if (top[1] === "name") {
|
||||
name = top[2].trim();
|
||||
descMode = false;
|
||||
continue;
|
||||
}
|
||||
if (top[1] === "description") {
|
||||
const rest = top[2].trim();
|
||||
descMode = rest === ">" || rest === ">-" || rest === "|";
|
||||
description = descMode ? "" : rest.replace(/^["']|["']$/g, "");
|
||||
continue;
|
||||
}
|
||||
descMode = false;
|
||||
continue;
|
||||
}
|
||||
if (descMode) {
|
||||
description += (description ? " " : "") + line.trim();
|
||||
continue;
|
||||
}
|
||||
if (inMetadata) {
|
||||
const t = line.trim();
|
||||
if (/^(tags|keywords):\s*/.test(t)) {
|
||||
const rest = t.replace(/^(tags|keywords):\s*/, "");
|
||||
if (rest) tags.push(...rest.split(/[,\s]+/).filter(Boolean));
|
||||
else tags.push(...[]); // valori su righe successive
|
||||
continue;
|
||||
}
|
||||
if (/^-\s*/.test(t)) tags.push(t.replace(/^-\s*/, ""));
|
||||
}
|
||||
}
|
||||
return { name, description: description.trim(), tags, body };
|
||||
}
|
||||
|
||||
function listDir(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readClamped(path: string): string {
|
||||
try {
|
||||
const buf = readFileSync(path);
|
||||
return buf.length > MAX_CHARS_PER_SKILL
|
||||
? buf.subarray(0, MAX_CHARS_PER_SKILL).toString("utf8")
|
||||
: buf.toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprint(dir: string): number {
|
||||
let acc = 0;
|
||||
try {
|
||||
for (const name of listDir(dir)) {
|
||||
const sdir = join(dir, name);
|
||||
acc += statSync(join(sdir, "SKILL.md")).mtimeMs;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- index
|
||||
|
||||
let index: IndexState | undefined;
|
||||
|
||||
function buildIndex(force = false): IndexState {
|
||||
if (index && !force) {
|
||||
const fp = fingerprint(SKILLS_DIR);
|
||||
if (fp === index.fingerprints.get(SKILLS_DIR)) return index;
|
||||
}
|
||||
const entries: SkillEntry[] = [];
|
||||
const fingerprints = new Map<string, number>();
|
||||
for (const name of listDir(SKILLS_DIR)) {
|
||||
const dir = join(SKILLS_DIR, name);
|
||||
const skillMdPath = join(dir, "SKILL.md");
|
||||
let raw = "";
|
||||
try {
|
||||
raw = readClamped(skillMdPath);
|
||||
} catch {
|
||||
continue; // no SKILL.md → non è una skill
|
||||
}
|
||||
const fm = parseFrontmatter(raw);
|
||||
const references: SkillEntry["references"] = [];
|
||||
try {
|
||||
for (const r of readdirSync(join(dir, "references"))) {
|
||||
if (!r.endsWith(".md")) continue;
|
||||
const rpath = join(dir, "references", r);
|
||||
references.push({ path: rpath, name: r, text: readClamped(rpath) });
|
||||
}
|
||||
} catch {
|
||||
/* nessuna references/ */
|
||||
}
|
||||
entries.push({
|
||||
name: fm.name || name,
|
||||
dir,
|
||||
skillMdPath,
|
||||
description: fm.description,
|
||||
tags: fm.tags,
|
||||
body: raw,
|
||||
references,
|
||||
mtime: 0,
|
||||
});
|
||||
}
|
||||
fingerprints.set(SKILLS_DIR, fingerprint(SKILLS_DIR));
|
||||
index = { entries, fingerprints, builtAt: Date.now() };
|
||||
return index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- search
|
||||
|
||||
function searchSkills(query: string, limit: number): { entry: SkillEntry; score: number; excerpt: string }[] {
|
||||
const idx = buildIndex();
|
||||
const tokens = tokenize(query);
|
||||
const out: { entry: SkillEntry; score: number; excerpt: string }[] = [];
|
||||
for (const entry of idx.entries) {
|
||||
const haystacks: [string, number][] = [
|
||||
[entry.name, 5],
|
||||
[entry.tags.join(" "), 4],
|
||||
[entry.description, 3],
|
||||
[(entry.body.match(/^#{1,3} .*$/gm) || []).join("\n"), 2],
|
||||
[entry.body, 1],
|
||||
[entry.references.map((r) => `${r.name}\n${r.text}`).join("\n"), 1],
|
||||
];
|
||||
let score = 0;
|
||||
let bestExcerpt = "";
|
||||
for (const tok of tokens) {
|
||||
for (const [text, weight] of haystacks) {
|
||||
const lower = text.toLowerCase();
|
||||
let pos = 0;
|
||||
let count = 0;
|
||||
while ((pos = lower.indexOf(tok, pos)) !== -1 && count < 5) {
|
||||
if (!bestExcerpt && weight >= 3) {
|
||||
bestExcerpt = text.slice(Math.max(0, pos - 120), pos + 180).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
score += weight;
|
||||
count++;
|
||||
pos += tok.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (score > 0) out.push({ entry, score, excerpt: bestExcerpt });
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- extension
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// indicizza all'avvio della sessione ("ad ogni avvio l'indice si aggiorna")
|
||||
pi.on("session_start", async () => {
|
||||
try {
|
||||
buildIndex(true);
|
||||
} catch {
|
||||
/* la scan avviene comunque lazy alla prima ricerca */
|
||||
}
|
||||
});
|
||||
|
||||
// istruzioni brevi nel system prompt (append, mai replace)
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const idx = buildIndex();
|
||||
const names = idx.entries.map((e) => e.name);
|
||||
if (names.length === 0) return;
|
||||
const block = [
|
||||
"## Skill Hub (pi-skill-hub)",
|
||||
`Skill condivise installate (${names.length}): ${names.join(", ")}.`,
|
||||
"Se il task richiede una competenza operativa specializzata e nessuna skill nota corrisponde, esegui skill_search prima di improvvisare; carica la skill scelta con /skill:<name> (o leggi il suo SKILL.md per il corpo completo). Le evidenze di esecuzione vanno registrate in qmem.",
|
||||
].join("\n");
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "skill_search",
|
||||
label: "Skill search",
|
||||
description:
|
||||
"Cerca nelle skill condivise (frontmatter, corpo e references) per nome, parola chiave o bisogno operativo. Ritorna le skill più rilevanti con estratti e indicazioni di caricamento. Usalo quando nessuna skill nota corrisponde al task o per ritrovare dettagli operativi (procedure, errori, recovery).",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Parole chiave o descrizione del bisogno operativo (anche in italiano)" }),
|
||||
limit: Type.Optional(Type.Number({ description: "Max risultati (default 5)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const hits = searchSkills(params.query, Math.max(1, params.limit ?? 5));
|
||||
if (hits.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Nessuna skill corrisponde alla ricerca." }],
|
||||
details: { hits: 0 },
|
||||
};
|
||||
}
|
||||
const text = hits
|
||||
.map(
|
||||
(h, i) =>
|
||||
`${i + 1}. ${h.entry.name} (score ${h.score})\n ${h.entry.description}\n Caricamento: /skill:${h.entry.name} | SKILL.md: ${h.entry.skillMdPath}\n Estratto: ${h.excerpt}`
|
||||
)
|
||||
.join("\n\n");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: {
|
||||
hits: hits.length,
|
||||
skills: hits.map((h) => ({ name: h.entry.name, path: h.entry.skillMdPath, score: h.score })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "skill_info",
|
||||
label: "Skill info",
|
||||
description:
|
||||
"Mostra il corpo completo di una skill (frontmatter, SKILL.md, elenco references). Usalo dopo skill_search per leggere la procedura prima di eseguire il task.",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Nome della skill (es. sap-timesheet)" }),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const idx = buildIndex();
|
||||
const entry = idx.entries.find((e) => e.name === params.name || e.skillMdPath.endsWith(params.name));
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Skill "${params.name}" non trovata. Usa skill_search per l'elenco.` }],
|
||||
details: { found: false },
|
||||
};
|
||||
}
|
||||
const body = entry.body.length > MAX_BODY_IN_RESULT ? entry.body.slice(0, MAX_BODY_IN_RESULT) + "\n…[troncato]" : entry.body;
|
||||
const refs = entry.references.map((r) => `- ${r.path}`).join("\n") || "(nessuna)";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `# ${entry.name}\n\n${entry.description}\n\nCaricamento: /skill:${entry.name}\n\n## SKILL.md\n${body}\n\n## References\n${refs}`,
|
||||
},
|
||||
],
|
||||
details: { found: true, name: entry.name, path: entry.skillMdPath, references: entry.references.map((r) => r.path) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("skill-sync", {
|
||||
description: "Ricostruisce l'indice delle skill del Skill Hub",
|
||||
handler: async (_name, ctx) => {
|
||||
const idx = buildIndex(true);
|
||||
ctx.ui.notify(
|
||||
`Skill Hub: ${idx.entries.length} skill indicizzate (${idx.entries.map((e) => e.name).join(", ")})`,
|
||||
"info",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "pi-skill-hub",
|
||||
"version": "0.1.0",
|
||||
"description": "Skill condivise pi: distribuzione via Gitea, ricerca indicizzata (skill_search) e caricamento dinamico. Le skill vivono qui; evidenze e dettagli macchina in qmem.",
|
||||
"keywords": ["pi-package", "skills", "registry", "skill-hub"],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./extensions"],
|
||||
"skills": ["./skills"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: sap-timesheet
|
||||
description: >
|
||||
Compila, corregge e verifica il timesheet mensile SAP CATS (S/4HANA WebGUI
|
||||
s4t.sap.telespazio.com, Manager desktop PPMDT → Inserimento Time Sheet) per il
|
||||
dipendente BENEDETTO MATTEO (21432). Usare quando l'utente chiede di inserire
|
||||
ore, completare/correggere il mese precedente o corrente, caricare le ore dal
|
||||
file Excel "Ore Team Fortuna", o verificare ore inserite vs teoriche. NON usare
|
||||
per gestione trasferte, ferie/assenze, autorizzazione timesheet di altri
|
||||
dipendenti o questioni payroll.
|
||||
---
|
||||
|
||||
# Timesheet SAP — completamento mese precedente e inserimento mese corrente
|
||||
|
||||
## Outcome
|
||||
Timesheet mensile salvato in SAP con **totale righe = ore teoriche del periodo**
|
||||
(e forecast 8h/giorno lavorativo sui giorni futuri), verificato con rilettura
|
||||
post-salvataggio, con evidenza osservata (DOM reale) di ogni valore scritto.
|
||||
|
||||
## Scope
|
||||
- Correzione/completamento del **mese precedente**: portare le ore inserite in
|
||||
linea con le ore teoriche ora generate (giorni mancanti + eccessi).
|
||||
- Inserimento del **mese corrente**: righe dati ripartite per WBS (percentuali
|
||||
dal file Excel ore team), giorni passati = teoriche, giorni lavorativi dopo
|
||||
oggi = 8,00 (forecast).
|
||||
- Verifica post-salvataggio e riconciliazione totali.
|
||||
|
||||
NON usare per: approvare timesheet di terzi, gestire assenze, trasferte, o
|
||||
modifiche a mesi chiusi da altro personale.
|
||||
|
||||
## Preconditions
|
||||
- Firefox con BiDi attivo: `browser_start(profile="main")` (profilo principale,
|
||||
login reali). Bridge su 127.0.0.1:8787, token `firefox-bidi-local`.
|
||||
- Sessione SAP autenticata (SSO Entra; l'MFA è manuale dell'utente).
|
||||
- Helper: `scripts/bridge_helper.py` (in questa skill; funzioni `ev`, `click_ctx`,
|
||||
`type_text`, `cell_center`, `cell_text`, `dialogs`, `write_cell`).
|
||||
- Se serve lo storico dei valori: qmem project `sap-timesheet`.
|
||||
- Fonte ore: Excel "Ore Team Fortuna - <Mese>.xlsx", Foglio3, colonne
|
||||
CdC/Reparto/Cognome/Nome/WBE/Perc<Mese> (righe BENEDETTO MATTEO).
|
||||
|
||||
## Non-negotiable gates
|
||||
1. **Piano approvato dall'utente PRIMA di scrivere** in SAP: presentare la
|
||||
tabella giorno-per-giorno (valori per riga WBS) e attendere conferma esplicita.
|
||||
2. **Ore teoriche = limite superiore** (riga r1 della griglia): mai superarle.
|
||||
Giorni passati → valore teorico esatto; mai 8,00 tondi su giornate parziali.
|
||||
3. **Mai ESC**: chiude la transazione. Dialoghi → pulsante "Continuare"/Invio.
|
||||
4. **Verifica = DOM reale** (BiDi eval), mai screenshot interpretato: ogni valore
|
||||
scritto è "Observed" solo se riletto dalla riga TOTALI (r2) o dopo re-render.
|
||||
Non convertire mai Unknown/Assumption in Observed.
|
||||
5. **Salvataggio solo dopo** riconciliazione: somma giorni per riga == totale
|
||||
riga, somma righe == totale generale; e **dopo approvazione utente**.
|
||||
6. **Verifica post-salvataggio obbligatoria** (riaprire la view e rileggere).
|
||||
7. Segregazione: l'agente **inserisce ma non approva**; il salvataggio finale
|
||||
viene eseguito solo con l'utente informato (attestazione del titolare).
|
||||
|
||||
## Workflow A — completamento/correzione mese precedente
|
||||
1. Naviga: Easy Access → dblclick preferito "Risorse umane -> Manager's Desktop"
|
||||
(dispatchEvent dblclick su `tree#C105#2#1#1#i`) → sezione Time Sheet →
|
||||
click destro su riga "BENEDETTO MATTEO" → "Inserimento Time Sheet".
|
||||
2. Imposta il periodo del mese precedente con i pulsanti **Videata
|
||||
precedente/successiva** (`M0:46:1::1:46` / `:1:49`, spostano di un mese).
|
||||
I campi data intestazione NON accettano digitazione sintetica.
|
||||
3. Leggi su DOM: riga teoriche (r1), riga totali (r2), righe dati (r3+).
|
||||
Costruisci il delta per ogni giorno: `inserite` vs `teoriche`.
|
||||
4. Presenta il piano (giorni mancanti con valore teorico esatto; eccessi da
|
||||
ridurre) e chiedi conferma.
|
||||
5. Scrivi le celle con la ricetta di `references/procedura-tecnica.md` §2,
|
||||
verificando ogni cella sulla riga totali (r2).
|
||||
6. Salva (§Salvataggio) e verifica (§Verifica).
|
||||
|
||||
## Workflow B — inserimento mese corrente con ripartizione WBS
|
||||
1. Apri la view sul mese corrente; leggi teoriche (r1) e righe esistenti.
|
||||
2. Calcola il piano: per ogni giorno con teoriche > 0 → ore = teorica; per i
|
||||
giorni lavorativi **dopo oggi** → 8,00; giorni con teorica 0 nel passato e
|
||||
weekend → 0. Oggi stesso escluso (si completa a fine mese col Workflow A).
|
||||
3. Ripartisci per riga WBS con le percentuali dell'Excel (es. 70/20/10 su
|
||||
185.102.01.01 / 225.489.64.50 / 235.900.10), arrotondando al centesimo e
|
||||
**bilanciando** perché la somma giornaliera = teorica del giorno.
|
||||
4. Presenta la tabella (giorno × riga WBS) e ottieni l'approvazione.
|
||||
5. Crea le righe: compila la prima riga (c4 TdA=ORE, c6 WBS, c8 UM=H; c2 CdC e
|
||||
c3 CA vengono auto-compilate da SAP dopo l'inserimento dei giorni), poi
|
||||
**seleziona la riga (colonna 0) → menu "..." → Copiare riga** per le successive,
|
||||
correggendo solo la WBS e i giorni.
|
||||
6. Scrivi i giorni di ogni riga (ricetta in references), verifica per riga e
|
||||
salva (§Salvataggio) + verifica (§Verifica).
|
||||
|
||||
## Salvataggio
|
||||
- Pulsante "Salvare Evidenziato" (`M0:50::btn[11]`).
|
||||
- Errori E bloccano il salvataggio (riportarli all'utente); i warning W si
|
||||
superano con "Continuare".
|
||||
- Esito OK = la view si chiude tornando al Manager desktop (nessun popup).
|
||||
- Se compare il dialogo di enqueue "La matricola 21432 è attualmente
|
||||
impegnata…" → premere Invio e vedi `references/errori-noti.md` §Lock.
|
||||
|
||||
## Verifica (exit criteria)
|
||||
- [ ] Riaperta la view e **rilegta la griglia** dal DOM reale.
|
||||
- [ ] Somma giorni per riga == totale riga; somma righe == totale generale.
|
||||
- [ ] Nessun giorno supera le teoriche; nessun giorno mancante rispetto al piano.
|
||||
- [ ] Valori registrati in qmem (project `sap-timesheet`) con i totali finali.
|
||||
- [ ] Rischi/limitazioni dichiarati (es. teoriche future non ancora generate).
|
||||
|
||||
## Resource loading
|
||||
| Condizione | Carica/esegui |
|
||||
|---|---|
|
||||
| Prima scrittura o ambiente cambiato | `references/procedura-tecnica.md` |
|
||||
| Errore SAP, lock, pagina bianca, menu chiuso | `references/errori-noti.md` |
|
||||
| Analisi di controllo/conformità richiesta | `references/best-practice-controlli.md` |
|
||||
| Operazioni ponte BiDi ripetitive | `scripts/bridge_helper.py` (python3) |
|
||||
|
||||
## Automation boundary
|
||||
- Lettura griglia/navigazione: autonoma.
|
||||
- Scrittura celle: dopo approvazione del piano (una volta per sessione).
|
||||
- Salvataggio: dopo riconciliazione OK; se compaiono errori E → fermarsi e
|
||||
riportarli; i warning W si confermano con "Continuare".
|
||||
- Mai ripetere un salvataggio fallito senza rianalisi: rischio duplicati
|
||||
(idempotenza: verificare sempre cosa è già persistito prima di riscrivere).
|
||||
|
||||
## Esempio (completamento agosto 2026, reale)
|
||||
Teoriche 107,18 vs inserite 61,05 → mancanti 04,06,07,19,20,21.08=8,00 e
|
||||
26.08=8,28; eccessi 24.08 8,00→6,87 e 31.08 8,00→6,98. Totale finale 107,18 =
|
||||
teoriche. Verificato con rilettura post-salvataggio. Cattiva risposta da evitare:
|
||||
"dichiarare completato" senza rilettura, o correggere eccessi senza approvazione.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Allineamento alle best practice di settore (SAP CATS + agent skills)
|
||||
|
||||
Fonti: documentazione SAP (help.sap.com — CATS lifecycle, target hours check,
|
||||
enhancement CATS0003/0006/0008, CATS_DA/CADO per audit); linee guida Agent
|
||||
Skills (agentskills.io, Anthropic, Microsoft Agent Framework) su progressive
|
||||
disclosure, hard gates, evidence standard e automation boundary. Verificate con
|
||||
ricerca web (Perplexity, 2026-09).
|
||||
|
||||
## Come la skill mappa i controlli CATS
|
||||
| Controllo di settore | Come è implementato nella skill |
|
||||
|---|---|
|
||||
| Lifecycle CATS: Capture → Validate → Release → Approve → Transfer → Reconcile | L'agente opera su Capture/Validate (Inserimento Time Sheet); Release/Approval restano all'utente (attestazione), Transfer è a valle |
|
||||
| Ore teoriche/target hours come vincolo di immissione (R1) | Regola non negoziabile: mai superare la riga teoriche; giornate parziali al valore esatto |
|
||||
| Validation hooks CATS0003/0006 lato SAP | La skill non aggira le validazioni: errori E → stop e report; warning W → "Continuare" solo dopo lettura del testo |
|
||||
| "RPA non corregge silenziosamente fatti di business" | I valori proposti derivano dal file Excel + teoriche SAP; ogni scostamento è proposto all'utente, mai autocorretto |
|
||||
| Human-in-the-loop / approval gate | Piano giorno-per-giorno approvato prima di scrivere; salvataggio dopo riconciliazione |
|
||||
| Idempotenza e no-duplicati | Prima di riscrivere si rilegge sempre lo stato persistito (R2); mai ri-salvare a caso dopo un errore |
|
||||
| Correlation ID / audit trail | Ogni sessione registra in qmem (project sap-timesheet): valori inseriti, totali, errori, esito verifica; il documento CATS ha numero univoco lato SAP |
|
||||
| Segregation of duties (entry ≠ approval) | L'agente non ha ruolo di approvazione; il titolare conferma il piano e il salvataggio |
|
||||
| Least privilege / credenziali | Si usa il profilo principale dell'utente via SSO; nessuna credenziale in script/prompt; BiDi solo loopback 127.0.0.1 |
|
||||
| Verifica come exit criterion | Rilettura post-salvataggio obbligatoria con riconciliazione somme |
|
||||
| Preferire API/scraping controllato | Non esistendo interfaccia utente alternativa per il profilo dipendente, si automatizza la WebGUI con click reali e verifica DOM (documentato; nessun bypass di controlli SAP) |
|
||||
|
||||
## Limiti dichiarati (residui)
|
||||
- L'automazione scrive con le credenziali dell'utente: ogni salvataggio è da
|
||||
considerarsi inserimento dell'utente stesso → l'attestazione finale è sua.
|
||||
- La ripartizione percentuale (70/20/10) deriva dall'Excel del team: è
|
||||
responsabilità dell'utente che il file sia quello approvato dal CdC.
|
||||
- Gli arrotondamenti al centesimo sono bilanciati per riga; le differenze di
|
||||
arrotondamento residue restano visibili nei totali mensili SAP.
|
||||
- CATS_DA (CADO) permette il controllo a posteriori dei documenti CATSDB:
|
||||
consigliata una riconciliazione mensile a campione dopo il transfer.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Errori noti e recovery (catalogo casi reali)
|
||||
|
||||
## 1. Lock enqueue: "La matricola 21432 è attualmente impegnata dall'utente MBENEDETTO"
|
||||
- Sintomo: aprendo "Inserimento Time Sheet" appare un popup Informazione
|
||||
("PREMERE INVIO PER CONTINUARE"); la schermata resta vuota/gray "SAP".
|
||||
- Causa: un'altra sessione server-side dello stesso utente tiene l'enqueue
|
||||
(es. istanza chiusa bruscamente o app morta).
|
||||
- Recovery verificata: premere Invio → si torna al Manager desktop. Attendere
|
||||
il decadimento; se persiste >15 min: **logoff completo** (Esci → conferma
|
||||
"Sì") e rientro (SSO trasparente, di solito senza MFA). Se sopravvive anche
|
||||
al re-login, il lock è tenuto da un'ALTRA sessione SAP dell'utente (SAP Logon
|
||||
su altro PC/browser): chiedere all'utente di chiuderla, oppure attendere il
|
||||
decay, o richiedere lo sblocco a chi gestisce SM12.
|
||||
- Nessun dato è perso: il lock blocca solo l'apertura in scrittura.
|
||||
|
||||
## 2. Pagina SAP bianca / app chiusa
|
||||
- Sintomo: screenshot bianco, `document.body` vuoto, title "".
|
||||
- Causa tipica: attivazione accidentale della voce di menu **"Chiudi
|
||||
applicazione"** nell'overflow del toolbar (coordinate fisse del menu!).
|
||||
- Recovery: `location.reload()` → Easy Access → rifare la navigazione. I dati
|
||||
NON salvati sono persi: ricostruire dal piano (che è sempre in memoria/Excel).
|
||||
|
||||
## 3. Menu overflow "..." — voci con coordinate variabili
|
||||
- Il menu ricorda lo scroll precedente: **rilocalizzare sempre la voce
|
||||
("Copiare riga") via DOM subito prima del click** e verificare che non sia
|
||||
"Chiudi applicazione". Dopo l'apertura della view le voci visibili sono
|
||||
diverse (alcuni pulsanti esistono nel DOM ma con rect 0x0).
|
||||
|
||||
## 4. Celle che non registrano
|
||||
- Digitazione diretta nei campi data intestazione: non funziona → usare Videata
|
||||
precedente/successiva.
|
||||
- Placeholder "Vuoto" di un periodo senza righe: la prima riga va creata con
|
||||
"Copiare riga" dal periodo precedente (o compilata con la ricetta §2 della
|
||||
procedura tecnica; il CdC/CA si auto-compilano dopo l'inserimento dei giorni).
|
||||
- Display vuoto dell'ultima cella modificata: artefatto, verificare su r2/c9.
|
||||
- Sequenza che rompe il commit: usare F2 sulle celle giorno al posto di Invio.
|
||||
Giorni → Invio (+Invio di conferma su popup); intestazione → F2 se Invio non basta.
|
||||
|
||||
## 5. Errori SAP di salvataggio (bloccanti E)
|
||||
- "ore maggiori di quelle effettuate" → un giorno supera le teoriche: ridurre al
|
||||
valore teorico.
|
||||
- "Inserire anche una tipo di attività mittente" → manca TdA (ORE) sulla riga.
|
||||
- "Versione 0 non definita" → CdC digitato a mano: preferire la riga copiata.
|
||||
- "Unità di misura X non creata" → UM errata (usare H).
|
||||
- I warning W non bloccano: si confermano con "Continuare"
|
||||
(es. "è presente una missione, verificare attribuzione").
|
||||
|
||||
## 6. Enqueue e teoriche progressive
|
||||
- Le teoriche dei giorni futuri vengono generate progressivamente: a metà mese
|
||||
la somma teoriche è parziale (agosto: 61,05 h il 22.08 → 107,18 h a fine mese).
|
||||
- Per questo il mese corrente si inserisce con forecast 8h sui giorni dopo
|
||||
oggi e si COMPLETA il mese successivo col Workflow A.
|
||||
|
||||
## 7. Crash della pagina / sessione caduta
|
||||
- "Maximum number of active sessions" → pkill firefox e `browser_start(main)`.
|
||||
- Dopo il reload ripartire da Easy Access; la sezione Manager desktop ricorda
|
||||
l'ultima usata (spesso non serve ricliccare "Time Sheet").
|
||||
@@ -0,0 +1,69 @@
|
||||
# Procedura tecnica — SAP WebGUI CATS via bridge BiDi
|
||||
|
||||
Tutto lo stato SAP si legge/scrive tramite il bridge BiDi (`127.0.0.1:8787`,
|
||||
header `Authorization: Bearer firefox-bidi-local`), helper in
|
||||
`scripts/bridge_helper.py`. Il modello non legge le immagini: la verità è il DOM.
|
||||
|
||||
## 1. Mappatura griglia (M0:46:2:1)
|
||||
- Righe: **r1 = ore teoriche**, **r2 = totali (autorevole per verifica)**,
|
||||
**r3.. = righe dati** (le "Vuoto" sono placeholder).
|
||||
- Colonne riga dati:
|
||||
- c1 TR/marker riga, c0 = colonna di selezione riga (0-based, a sinistra)
|
||||
- c2 CdC mittente (CATSD-SKOSTL) — auto-compilata da SAP (493)
|
||||
- c3 CA / area contabile (CATSD-KOKRS) — auto-compilata (EGEO); non digitare
|
||||
- c4 TdA (CATSD-LSTAR) = ORE (obbligatoria)
|
||||
- c6 Elemento WBS destinatario (CATSD-RPROJ)
|
||||
- c7 Tp.A/P (vuoto), c8 UM (CATSD-UNIT) = H
|
||||
- c9 Totale riga
|
||||
- Giorni: **colonna = 9 + numero del giorno** (c10 = giorno 01, c25 = giorno 16…).
|
||||
- L'header visivo è sfasato rispetto agli ID cella: fidarsi solo di lsdata/SID.
|
||||
|
||||
## 2. Ricetta di scrittura cella (verificata)
|
||||
1. `scrollIntoView({block:'center', inline:'nearest'})` + 500-600ms; SE il punto
|
||||
resta fuori viewport (x<0 o >1376), trascinare il thumb della hscroll custom
|
||||
(`M0:46:2:1_hscroll-hdl`) verso il lato giusto e rilleggere il rect. Mai
|
||||
coordinate fuori viewport (HTTP 500 "Move target out of bounds").
|
||||
2. Click reale BiDi: pointerMove → pointerDown → pausa 90ms → pointerUp → 450ms.
|
||||
3. Digitazione: Ctrl+A (\ue009 + 'a' + \ue00a) → caratteri uno a uno con pause
|
||||
110ms → **Invio \ue007**.
|
||||
4. Se resta aperto il popup "N proposte disponibili" (value-help storico):
|
||||
confermare con un ulteriore Invio, o cliccare l'item storico
|
||||
(`M0:46:2:1::<r>:<c>_TALB`); in extremis F2 (\ue002) committa.
|
||||
5. ATTENZIONE: **Invio funziona sulle celle giorno; sulle celle intestazione
|
||||
(TdA/WBS/UM) spesso serve F2.** Se dopo la digitazione il display resta
|
||||
vuoto è spesso un artefatto: verificare sulla riga TOTALI (r2) o dopo il
|
||||
click sulla cella successiva (il blur committa).
|
||||
6. Verifica per ogni cella: `tot == atteso` sulla riga r2 della stessa colonna
|
||||
(con più righe dati, tot = somma: verificare la riga con `r.c9` dopo
|
||||
re-render, o i totali come somma attesa).
|
||||
7. Il display dell'ULTIMA cella modificata spesso legge '' (artefatto): non
|
||||
considerarlo fallimento se i totali tornano.
|
||||
|
||||
## 3. Navigazione
|
||||
- Easy Access → dblclick preferito "Risorse umane -> Manager's Desktop":
|
||||
`el.dispatchEvent(new MouseEvent('dblclick', {bubbles:true}))` su
|
||||
`tree#C105#2#1#1#i` (il dblclick nativo BiDi clickCount NON funziona).
|
||||
- Sezione Time Sheet: pulsante toolbar "Time Sheet" (cambia contesto Manager desktop).
|
||||
- View acquisizione: click destro (button 2) sulla riga BENEDETTO
|
||||
(`tree#C121#1#1#mg`, cercare per testo) → voce "Inserimento Time Sheet".
|
||||
- Cambio periodo: pulsanti **Videata precedente** `M0:46:1::1:46` /
|
||||
**Videata successiva** `M0:46:1::1:49` (±1 mese). I campi data
|
||||
(`M0:46:1::1:21/34`) non accettano key sintetici.
|
||||
- Riga nuova: selezionare la riga di partenza (click su colonna 0, verifica
|
||||
classe `lsSTTDScSel2BgColor`) → menu "..." (toolbar, ~x=965,y=72) →
|
||||
**"Copiare riga"**. Individuare la voce di menu FRESA ad ogni apertura
|
||||
(il menu ricorda lo scroll: coordinate fisse possono centrare
|
||||
"Chiudi applicazione" → vedi errori-noti §3).
|
||||
|
||||
## 4. Salvataggio e verifica
|
||||
- "Salvare Evidenziato" `M0:50::btn[11]`; OK = view chiusa su Manager desktop.
|
||||
- Riaprire la view, riallineare il periodo (Videata precedente/successiva) e
|
||||
rileggere tutta la griglia; riconciliare le somme.
|
||||
- Attenzione: dopo riapertura il periodo torna al mese corrente.
|
||||
|
||||
## 5. Helper
|
||||
`scripts/bridge_helper.py`: `ev(expr)` eval JS; `click_ctx(x,y[,button])`;
|
||||
`type_text(text, commit)` (Ctrl+A + char-per-char 110ms + commit);
|
||||
`cell_center(r,c)` (scrollIntoView + rect); `cell_text(r,c)`; `dialogs()`;
|
||||
`write_cell(r,c,text)`. Chiavi unicode: Invio `\ue007`, F2 `\ue002`, Ctrl `\ue009`.
|
||||
Mai inviare `value: ""` a input.performActions (il bridge rifiuta).
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helper SAP timesheet via bridge BiDi (127.0.0.1:8787, token firefox-bidi-local)."""
|
||||
import json, sys, time, urllib.request
|
||||
|
||||
BRIDGE = "http://127.0.0.1:8787"
|
||||
TOKEN = "firefox-bidi-local"
|
||||
HDR = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
|
||||
|
||||
def post(path, payload):
|
||||
req = urllib.request.Request(BRIDGE + path, data=json.dumps(payload).encode(), headers=HDR, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
def ev(expr):
|
||||
return post("/eval", {"expression": expr, "awaitPromise": True})
|
||||
|
||||
def cmd(method, params):
|
||||
return post("/cmd", {"method": method, "params": params})
|
||||
|
||||
def click(x, y, button=0):
|
||||
return cmd("input.performActions", {"context": _ctx(), "actions": [{"id": "m", "type": "pointer", "parameters": {"pointerType": "mouse"}, "actions": [
|
||||
{"type": "pointerMove", "x": x, "y": y, "origin": "viewport"},
|
||||
{"type": "pointerDown", "button": button}, {"type": "pause", "duration": 90},
|
||||
{"type": "pointerUp", "button": button}, {"type": "pause", "duration": 450}]}]})
|
||||
|
||||
_CTX = None
|
||||
def _ctx():
|
||||
global _CTX
|
||||
if _CTX is None:
|
||||
_CTX = post("/status", {})["context"] if False else None
|
||||
# /status è GET
|
||||
return _CTX
|
||||
|
||||
def status():
|
||||
req = urllib.request.Request(BRIDGE + "/status", headers=HDR)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
def ctx():
|
||||
global _CTX
|
||||
if _CTX is None:
|
||||
_CTX = status()["context"]
|
||||
return _CTX
|
||||
|
||||
def click_ctx(x, y, button=0):
|
||||
return cmd("input.performActions", {"context": ctx(), "actions": [{"id": "m", "type": "pointer", "parameters": {"pointerType": "mouse"}, "actions": [
|
||||
{"type": "pointerMove", "x": x, "y": y, "origin": "viewport"},
|
||||
{"type": "pointerDown", "button": button}, {"type": "pause", "duration": 90},
|
||||
{"type": "pointerUp", "button": button}, {"type": "pause", "duration": 450}]}]})
|
||||
|
||||
CTRL = "\ue009"; ENTER = "\ue007"; F2 = "\ue002"
|
||||
|
||||
def type_text(text, commit=ENTER):
|
||||
acts = [{"type": "keyDown", "value": CTRL}, {"type": "keyDown", "value": "a"}, {"type": "keyUp", "value": "a"}, {"type": "keyUp", "value": CTRL}, {"type": "pause", "duration": 200}]
|
||||
for ch in text:
|
||||
acts += [{"type": "keyDown", "value": ch}, {"type": "keyUp", "value": ch}, {"type": "pause", "duration": 110}]
|
||||
acts += [{"type": "pause", "duration": 250}, {"type": "keyDown", "value": commit}, {"type": "keyUp", "value": commit}, {"type": "pause", "duration": 400}]
|
||||
return cmd("input.performActions", {"context": ctx(), "actions": [{"id": "k", "type": "key", "actions": acts}]})
|
||||
|
||||
def cell_center(r, c):
|
||||
out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); if (!e) return JSON.stringify({{err:'nofound'}}); e.scrollIntoView({{block:'center', inline:'center'}}); return 'ok'; }})()")
|
||||
time.sleep(0.6)
|
||||
out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); const b = e.getBoundingClientRect(); return JSON.stringify({{x: Math.round(b.x + b.width/2), y: Math.round(b.y + b.height/2)}}); }})()")
|
||||
v = out.get("result", out)
|
||||
if isinstance(v, str):
|
||||
v = json.loads(v)
|
||||
return v
|
||||
|
||||
def dialogs():
|
||||
out = ev("""(() => { const d = [...document.querySelectorAll('[role="dialog"], .urDlg, [class*="Dialog"], [class*="dialog"]')].filter(e => e.getBoundingClientRect().width > 0).map(e => (e.textContent||'').trim().slice(0,120)); return JSON.stringify(d); })()""")
|
||||
v = out.get("result", "")
|
||||
return json.loads(v) if isinstance(v, str) else v
|
||||
|
||||
def cell_text(r, c):
|
||||
out = ev(f"(() => {{ const e = document.getElementById('M0:46:2:1[{r},{c}]'); return e ? (e.textContent||'').trim() : null; }})()")
|
||||
v = out.get("result")
|
||||
return v
|
||||
|
||||
def write_cell(r, c, text, expected=None):
|
||||
"""Scrive text nella cella [r,c]: click reale, Ctrl+A, digitazione, Invio. Ritorna dict esito."""
|
||||
p = cell_center(r, c)
|
||||
if "err" in p:
|
||||
return {"error": p["err"]}
|
||||
click_ctx(p["x"], p["y"])
|
||||
time.sleep(0.2)
|
||||
type_text(text)
|
||||
time.sleep(0.5)
|
||||
val = cell_text(r, c)
|
||||
exp = expected if expected is not None else text
|
||||
if val != exp:
|
||||
# retry con F2 come commit
|
||||
type_text(text, commit=F2)
|
||||
time.sleep(0.5)
|
||||
val = cell_text(r, c)
|
||||
return {"cell": f"[{r},{c}]", "sent": text, "read": val, "ok": val == exp, "dialogs": dialogs()}
|
||||
|
||||
if __name__ == "__main__":
|
||||
what = sys.argv[1]
|
||||
if what == "grid":
|
||||
cols = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(2, 41))
|
||||
rows = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [1, 2, 3]
|
||||
res = {}
|
||||
for r in rows:
|
||||
res[r] = {c: cell_text(r, c) for c in cols}
|
||||
print(json.dumps(res, ensure_ascii=False))
|
||||
elif what == "dialogs":
|
||||
print(json.dumps(dialogs(), ensure_ascii=False))
|
||||
elif what == "write":
|
||||
r, c, text = int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
|
||||
print(json.dumps(write_cell(r, c, text), ensure_ascii=False))
|
||||
elif what == "center":
|
||||
r, c = int(sys.argv[2]), int(sys.argv[3])
|
||||
print(json.dumps(cell_center(r, c)))
|
||||
elif what == "click":
|
||||
x, y = int(sys.argv[2]), int(sys.argv[3])
|
||||
b = int(sys.argv[4]) if len(sys.argv) > 4 else 0
|
||||
click_ctx(x, y, b)
|
||||
print("clicked", x, y, "btn", b)
|
||||
Reference in New Issue
Block a user