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:
2026-09-24 12:15:27 +02:00
commit 551b5f3611
7 changed files with 734 additions and 0 deletions
+321
View File
@@ -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",
);
},
});
}
+15
View File
@@ -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"]
}
}
+122
View File
@@ -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)