Skip to content

Commit 6d94a00

Browse files
Gabrymi93claudematteocavo
authored
feat: integra segnali cross-repo (SO catalog_signals + DI pipeline_signals) (#5)
* feat: fetch source-observatory signals from GitHub Adds GitHubCollector.get_raw_file() for fetching raw file content from raw.githubusercontent.com (no token required on public repos). The renderer now reads source-observatory/data/catalog/catalog_signals.json at build time and injects a Source Health section into session_bootstrap.md and a source_health key into workspace_triage.json. - signals.py: dataclasses + parser (parse_source_observatory_signals) — pure, no I/O, 100% covered - Renderer caches the fetch result so bootstrap + triage share one HTTP call - Graceful degradation: unavailable file → labelled error, no crash Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: define repo-signals standard for cross-repo ACB integration Shared spec for JSON signal files that Lab repos publish for ACB to consume. Covers schema v1 (envelope + signals array with ok/warn/error status), topic conventions, legacy SO format note, and onboarding steps for new repos. Enables dataset-incubator to produce pipeline_signals.json following this standard as the next integration step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: integrate dataset-incubator pipeline_signals into ACB context Adds RepoSignal/RepoSignals dataclasses and parse_repo_signals() in signals.py for consuming the repo-signals standard v1. Renderer now fetches dataset-incubator/registry/pipeline_signals.json and injects: - Pipeline State section in session_bootstrap.md (warn/error candidates only) - pipeline_state key in workspace_triage.json Cache ensures both SO and DI signals are fetched exactly once per build. DI signals degrade gracefully until feat/pipeline-signals is merged to main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: chiarisce semantica di status:ok in repo-signals standard Il significato di ok è topic-dipendente. Per pipeline_state, ok indica struttura candidate valida + mart SQL presente, non readiness completa. Aggiunta nota esplicita per i consumer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: risolvi 3 MEDIUM da review Matteo su feat/github-signals - sposta _UNSET dopo gli import (PEP 8) - rendi regressions e alerts mutualmente esclusivi in SourceObservatorySignals - aggiunge test diretti per parse_repo_signals e RepoSignals - cache test usa side_effect con JSON realistici distinti per SO e DI --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Matteo <matteo.cavo.data@gmail.com>
1 parent 7d25a75 commit 6d94a00

6 files changed

Lines changed: 806 additions & 1 deletion

File tree

schemas/repo-signals.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Repo Signals — Standard di interoperabilità per ACB
2+
3+
Spec per i file JSON che i repo del Lab possono pubblicare affinché
4+
`agent-context-builder` li consumi durante il build del contesto.
5+
6+
---
7+
8+
## Scopo
9+
10+
Ogni repo ha conoscenza locale che ACB non può derivare da GitHub:
11+
- **source-observatory**: salute delle fonti dati (endpoint up/down, regressioni)
12+
- **dataset-incubator**: stato dei candidati nel pipeline (stage, blocchi, pronti per promozione)
13+
- (futuri repo): qualunque segnale operativo rilevante per un agente
14+
15+
Anziché costruire parser ad-hoc per ogni repo, ACB legge file JSON che seguono
16+
questo standard e li aggrega in `workspace_triage.json` e `session_bootstrap.md`.
17+
18+
---
19+
20+
## Convenzione di posizionamento
21+
22+
Ogni repo pubblica il proprio file in una path nota e stabile, committata nel
23+
repo stesso e aggiornata da CI (o manualmente). ACB la legge via
24+
`raw.githubusercontent.com` — nessun clone locale necessario.
25+
26+
Paths per repo attivi:
27+
28+
| Repo | Path nel repo | Nota |
29+
|------|--------------|------|
30+
| `source-observatory` | `data/catalog/catalog_signals.json` | formato legacy (vedi §Legacy) |
31+
| `dataset-incubator` | `registry/pipeline_signals.json` | adotta questo standard |
32+
33+
---
34+
35+
## Schema (v1)
36+
37+
```json
38+
{
39+
"schema_version": "1",
40+
"generated_at": "2026-04-15",
41+
"repo": "dataset-incubator",
42+
"topic": "pipeline_state",
43+
"summary": {
44+
"total": 20,
45+
"by_status": { "ok": 16, "warn": 3, "error": 1 }
46+
},
47+
"signals": [
48+
{
49+
"id": "irpef-comunale",
50+
"status": "ok",
51+
"label": "irpef-comunale",
52+
"detail": "stage: incubating — anni 2019-2023, fonte: mef",
53+
"action": ""
54+
},
55+
{
56+
"id": "ispra-balneazione",
57+
"status": "warn",
58+
"label": "ispra-balneazione",
59+
"detail": "stage: incubating — nessun mart SQL, issue aperta da 60+ giorni",
60+
"action": "valutare se bloccare o archiviare"
61+
}
62+
]
63+
}
64+
```
65+
66+
### Campi obbligatori
67+
68+
| Campo | Tipo | Descrizione |
69+
|-------|------|-------------|
70+
| `schema_version` | `"1"` | Versione dello schema (stringa, non numero) |
71+
| `generated_at` | `YYYY-MM-DD` | Data di generazione |
72+
| `repo` | string | Nome del repo GitHub (es. `"dataset-incubator"`) |
73+
| `topic` | string | Dominio del segnale — vedi §Topic |
74+
| `signals` | array | Lista di segnali — vedi §Segnale |
75+
76+
### Campi opzionali
77+
78+
| Campo | Tipo | Descrizione |
79+
|-------|------|-------------|
80+
| `summary` | object | Conteggi aggregati per status |
81+
| `summary.total` | int | Totale elementi osservati |
82+
| `summary.by_status` | object | Conteggi per valore di status |
83+
84+
---
85+
86+
## Valori di `topic`
87+
88+
| Valore | Repo | Significato |
89+
|--------|------|-------------|
90+
| `catalog_health` | source-observatory | Salute degli endpoint delle fonti |
91+
| `pipeline_state` | dataset-incubator | Stato dei candidati nel pipeline |
92+
93+
Nuovi topic: aprire issue in `agent-context-builder` prima di adottarli,
94+
per coordinare il parser ACB.
95+
96+
---
97+
98+
## Schema del segnale (`signals[]`)
99+
100+
| Campo | Tipo | Obbligatorio | Descrizione |
101+
|-------|------|-------------|-------------|
102+
| `id` | string || Identificatore stabile (slug del dataset, nome fonte, ecc.) |
103+
| `status` | `"ok"` \| `"warn"` \| `"error"` || Stato sintetico |
104+
| `label` | string || Nome leggibile da mostrare in UI/bootstrap |
105+
| `detail` | string || Descrizione human-readable dello stato corrente |
106+
| `action` | string || Azione suggerita — stringa vuota `""` se nessuna |
107+
108+
### Semantica di `status`
109+
110+
| Valore | Significato |
111+
|--------|-------------|
112+
| `ok` | Nessun problema noto, nessuna azione richiesta |
113+
| `warn` | Attenzione: qualcosa è anomalo ma non bloccante |
114+
| `error` | Bloccante o regressione: richiede azione |
115+
116+
ACB mostra nel `session_bootstrap.md` solo i segnali `warn` e `error`.
117+
I segnali `ok` appaiono solo nei conteggi di `summary`.
118+
119+
**Nota per i consumer**: il significato di `ok` dipende dal `topic` del file.
120+
Per `pipeline_state` (dataset-incubator), `ok` significa **struttura candidate
121+
coerente e layer mart presente** — non implica che la pipeline abbia girato,
122+
che i dati siano freschi o che il candidate sia pronto per la promozione.
123+
Leggere sempre il campo `detail` per il contesto specifico del segnale.
124+
125+
---
126+
127+
## Legacy: source-observatory
128+
129+
`source-observatory` usa un formato precedente allo standard (`catalog_signals.json`).
130+
ACB mantiene un parser dedicato (`signals.py::parse_source_observatory_signals`)
131+
che mappa il vecchio formato sul modello interno.
132+
133+
Migrazione pianificata: quando SO adotterà questo schema, il parser legacy
134+
verrà rimosso. Non c'è urgenza — il formato SO è stabile e ben definito.
135+
136+
---
137+
138+
## Come aggiungere un nuovo repo
139+
140+
1. Definire il `topic` (aprire issue in ACB se non esiste)
141+
2. Scrivere lo script che produce il JSON (es. `scripts/build_pipeline_signals.py`)
142+
3. Aggiungere il file alla CI del repo (workflow che lo aggiorna e committa)
143+
4. Registrare la path in ACB (`dataciviclab.config.yml` o nel codice di `GitHubCollector`)
144+
5. Aggiungere il parser in `render.py` se il topic non è già supportato

src/agent_context_builder/github.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,32 @@ def _get_repo_prs(self, repo: str, state: str = "open") -> list[PR]:
104104
)
105105
return prs
106106

107+
def get_raw_file(self, repo: str, path: str, ref: str = "main") -> str | None:
108+
"""Fetch raw file content from GitHub.
109+
110+
Uses raw.githubusercontent.com — works without token on public repos.
111+
On failure, records the error in self.fetch_errors and returns None.
112+
113+
Args:
114+
repo: Repository name (under self.org)
115+
path: File path within the repo
116+
ref: Branch or tag (default: main)
117+
118+
Returns:
119+
Raw file content as string, or None on failure.
120+
"""
121+
url = f"https://raw.githubusercontent.com/{self.org}/{repo}/{ref}/{path}"
122+
headers = {}
123+
if self.token:
124+
headers["Authorization"] = f"token {self.token}"
125+
try:
126+
response = requests.get(url, headers=headers, timeout=10)
127+
response.raise_for_status()
128+
return response.text
129+
except Exception as exc:
130+
self.fetch_errors[f"{repo}:{path}"] = str(exc)
131+
return None
132+
107133
def _get_repo_issues(self, repo: str, state: str = "open") -> list[Issue]:
108134
"""Get issues for a specific repo (excluding pull requests).
109135

src/agent_context_builder/render.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
from .discussions import Discussion, DiscussionCollector
88
from .github import GitHubCollector, PR
99
from .git_local import GitLocalCollector, GitState
10+
from .signals import (
11+
RepoSignals,
12+
SourceObservatorySignals,
13+
parse_repo_signals,
14+
parse_source_observatory_signals,
15+
)
16+
17+
18+
class _UNSET:
19+
"""Sentinel for uninitialized cache values."""
1020

1121

1222
class Renderer:
@@ -34,6 +44,9 @@ def __init__(
3444
self.git_collector = git_collector
3545
self.discussion_collector = discussion_collector
3646
self.fixed_timestamp = fixed_timestamp or datetime.now().isoformat()
47+
# Cache for remote signal fetches — avoids double requests within one build
48+
self._so_signals_cache: SourceObservatorySignals | None | type[_UNSET] = _UNSET
49+
self._di_signals_cache: RepoSignals | None | type[_UNSET] = _UNSET
3750

3851
def render_session_bootstrap(self) -> str:
3952
"""Render session_bootstrap.md.
@@ -113,8 +126,65 @@ def render_session_bootstrap(self) -> str:
113126
lines.append(f"- {topic_name}")
114127
lines.append("")
115128

129+
# Source health (only issues — skip stable sources)
130+
so = self._fetch_source_observatory_signals()
131+
lines += self._render_source_health_section(so)
132+
133+
# Pipeline state (only warn/error candidates)
134+
di = self._fetch_di_pipeline_signals()
135+
lines += self._render_pipeline_state_section(di)
136+
116137
return "\n".join(lines)
117138

139+
def _fetch_source_observatory_signals(self) -> SourceObservatorySignals | None:
140+
if self._so_signals_cache is not _UNSET:
141+
return self._so_signals_cache # type: ignore[return-value]
142+
raw = self.github_collector.get_raw_file(
143+
"source-observatory", "data/catalog/catalog_signals.json"
144+
)
145+
if raw is None:
146+
self._so_signals_cache = None
147+
return None
148+
try:
149+
result = parse_source_observatory_signals(raw)
150+
except ValueError as exc:
151+
self.github_collector.fetch_errors["source-observatory:catalog_signals"] = str(exc)
152+
result = None
153+
self._so_signals_cache = result
154+
return result
155+
156+
def _render_source_health_section(
157+
self, so: SourceObservatorySignals | None
158+
) -> list[str]:
159+
lines = []
160+
lines.append("## Source Health")
161+
lines.append("")
162+
if so is None:
163+
err = self.github_collector.fetch_errors.get(
164+
"source-observatory:data/catalog/catalog_signals.json"
165+
) or self.github_collector.fetch_errors.get(
166+
"source-observatory:catalog_signals"
167+
)
168+
if err:
169+
lines.append(f"> *catalog_signals unavailable — {err}*")
170+
else:
171+
lines.append("> *catalog_signals unavailable*")
172+
lines.append("")
173+
return lines
174+
175+
# Show regressions first, then other alerts (mutually exclusive sets)
176+
issues = so.regressions + so.alerts
177+
if issues:
178+
for s in issues:
179+
lines.append(f"- **{s.source}** ({s.protocol}): {s.result}{s.detail}")
180+
if s.suggested_action and s.suggested_action != "nessuna":
181+
lines.append(f" - azione: {s.suggested_action}")
182+
else:
183+
lines.append(f"*All {so.sources_checked} sources stable* (as of {so.captured_at})")
184+
lines.append(f" *(captured {so.captured_at}, {so.sources_checked} sources checked)*")
185+
lines.append("")
186+
return lines
187+
118188
def render_workspace_triage(self) -> dict[str, Any]:
119189
"""Render workspace_triage.json.
120190
@@ -186,9 +256,120 @@ def render_workspace_triage(self) -> dict[str, Any]:
186256
for repo, state in repos_state.items()
187257
},
188258
"warnings": self._collect_warnings(prs, repos_state),
259+
"source_health": self._build_source_health_dict(),
260+
"pipeline_state": self._build_pipeline_state_dict(),
189261
}
190262
return triage
191263

264+
def _build_source_health_dict(self) -> dict[str, Any]:
265+
so = self._fetch_source_observatory_signals()
266+
if so is None:
267+
return {
268+
"available": False,
269+
"errors": {
270+
k: v for k, v in self.github_collector.fetch_errors.items()
271+
if "source-observatory" in k
272+
},
273+
}
274+
return {
275+
"available": True,
276+
"captured_at": so.captured_at,
277+
"sources_checked": so.sources_checked,
278+
"regressions": [
279+
{
280+
"source": s.source,
281+
"protocol": s.protocol,
282+
"detail": s.detail,
283+
"suggested_action": s.suggested_action,
284+
}
285+
for s in so.regressions
286+
],
287+
"alerts": [
288+
{
289+
"source": s.source,
290+
"protocol": s.protocol,
291+
"signal_type": s.signal_type,
292+
"result": s.result,
293+
"detail": s.detail,
294+
"suggested_action": s.suggested_action,
295+
}
296+
for s in so.alerts
297+
],
298+
}
299+
300+
def _fetch_di_pipeline_signals(self) -> RepoSignals | None:
301+
if self._di_signals_cache is not _UNSET:
302+
return self._di_signals_cache # type: ignore[return-value]
303+
raw = self.github_collector.get_raw_file(
304+
"dataset-incubator", "registry/pipeline_signals.json"
305+
)
306+
if raw is None:
307+
self._di_signals_cache = None
308+
return None
309+
try:
310+
result = parse_repo_signals(raw)
311+
except ValueError as exc:
312+
self.github_collector.fetch_errors["dataset-incubator:pipeline_signals"] = str(exc)
313+
result = None
314+
self._di_signals_cache = result
315+
return result
316+
317+
def _render_pipeline_state_section(self, di: RepoSignals | None) -> list[str]:
318+
lines = []
319+
lines.append("## Pipeline State")
320+
lines.append("")
321+
if di is None:
322+
err = self.github_collector.fetch_errors.get(
323+
"dataset-incubator:registry/pipeline_signals.json"
324+
) or self.github_collector.fetch_errors.get(
325+
"dataset-incubator:pipeline_signals"
326+
)
327+
if err:
328+
lines.append(f"> *pipeline_signals unavailable — {err}*")
329+
else:
330+
lines.append("> *pipeline_signals unavailable*")
331+
lines.append("")
332+
return lines
333+
334+
summary = di.summary
335+
total = summary.get("total", len(di.signals))
336+
by_status = summary.get("by_status", {})
337+
actionable = di.actionable
338+
if actionable:
339+
for s in actionable:
340+
lines.append(f"- **{s.label}** [{s.status}]: {s.detail}")
341+
if s.action:
342+
lines.append(f" - azione: {s.action}")
343+
else:
344+
lines.append(f"*{total} candidates, tutti ok*")
345+
lines.append(
346+
f" *(as of {di.generated_at} — "
347+
+ ", ".join(f"{v} {k}" for k, v in sorted(by_status.items()) if v)
348+
+ ")*"
349+
)
350+
lines.append("")
351+
return lines
352+
353+
def _build_pipeline_state_dict(self) -> dict[str, Any]:
354+
di = self._fetch_di_pipeline_signals()
355+
if di is None:
356+
return {
357+
"available": False,
358+
"errors": {
359+
k: v for k, v in self.github_collector.fetch_errors.items()
360+
if "dataset-incubator" in k
361+
},
362+
}
363+
return {
364+
"available": True,
365+
"generated_at": di.generated_at,
366+
"summary": di.summary,
367+
"actionable": [
368+
{"id": s.id, "status": s.status, "detail": s.detail, "action": s.action}
369+
for s in di.actionable
370+
],
371+
}
372+
192373
def _collect_warnings(
193374
self, prs: list[PR], repos_state: dict[str, GitState]
194375
) -> list[str]:

0 commit comments

Comments
 (0)