Skip to content

Commit d67107f

Browse files
committed
chore: update .gitignore to exclude generated reports and remove outdated documentation file
1 parent 1d69de6 commit d67107f

16 files changed

Lines changed: 2328 additions & 26 deletions

File tree

.agent_history.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Agent history — ISS
2+
3+
## Job 4 — verify-kernelbot-sync (2026-05-19)
4+
5+
- **Script:** `.github/scripts/verify-kernelbot-sync.mjs` — tripla verificação: `validate-report.json` (SSOT), `COUNT(DISTINCT discipline, slug)` no MySQL (critério ingest), `GET /health/catalog` com Bearer.
6+
- **Workflow:** job `verify` em `sync-kernelbot-knowledge.yml``needs: [validate, reload]`, download de artefatos em `.github/reports/`, upload `verify-report.json`.
7+
- **reload-kernelbot.mjs:** comentário no topo (getReader + TextDecoder, sem `setEncoding`); flush opcional do decoder após o loop SSE.
8+
- **Verificação local:** `node --check .github/scripts/verify-kernelbot-sync.mjs` e `reload-kernelbot.mjs`.
9+
10+
## Fase 5b documentada (2026-05-19)
11+
12+
- **Doc:** `documentation.md` — secção «Pipeline de Sincronização e Ingestão Automática (Fase 5b)» (Jobs J1–J5, mermaid, secrets, rede GHA, BM25, sem auto-heal).
13+
- **Agentes:** `agents/documentation.md` — pointer para SSOT na raiz.
14+
- continuar
15+
16+
## Fase 5b chunks enriquecidos (2026-05-20)
17+
18+
- **Script:** `.github/scripts/ingest-knowledge.py` — J2 passa a ler `jsons/{discipline}/{discipline}__NN__{slug}.json` (não `.md`); cabeçalho `[CONTEXTO DA AULA]` + chunking 500/50 (espelha `KernelBot/engine/database.py`); chunks concatenados com `\n\n` no campo MySQL `content`.
19+
- **Risco aceite:** chunking duplo no runtime do KernelBot permanece até alinhar `fetch_db_chunks`.
20+
- continuar

.github/reports/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
15.9 KB
Binary file not shown.
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Ingest ISS content/lessons.json into KernelBot MySQL `knowledge` table.
4+
5+
Lê JSONs enriquecidos em jsons/<discipline>/, fatia o conteúdo (~500 palavras,
6+
overlap 50 — espelha KernelBot/engine/database.py), prefixa cada chunk com
7+
cabeçalho de contexto e persiste todos os chunks concatenados com \\n\\n no
8+
campo `content` (uma row por lição).
9+
10+
Normalization matches .github/scripts/validate-catalog.mjs (strip, lower, _ → -).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import os
17+
import re
18+
import sys
19+
from datetime import datetime, timezone
20+
from pathlib import Path
21+
22+
import pymysql
23+
24+
ROOT = Path(__file__).resolve().parents[2]
25+
LESSONS_PATH = ROOT / "content" / "lessons.json"
26+
JSONS_DIR = ROOT / "jsons"
27+
REPORT_DIR = ROOT / ".github" / "reports"
28+
REPORT_PATH = REPORT_DIR / "ingest-report.json"
29+
30+
FRONTMATTER_RE = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n?", re.DOTALL)
31+
32+
# Espelha KernelBot/engine/database.py (DB_CHUNK_WORDS / DB_CHUNK_OVERLAP)
33+
CHUNK_WORDS = 500
34+
CHUNK_OVERLAP = 50
35+
# Separador entre chunks enriquecidos na string persistida em MySQL (1 row/lição)
36+
CHUNK_SEPARATOR = "\n\n"
37+
38+
UPSERT_SQL = """
39+
INSERT INTO knowledge (discipline, slug, title, `order`, content, active)
40+
VALUES (%s, %s, %s, %s, %s, 1)
41+
ON DUPLICATE KEY UPDATE
42+
title = VALUES(title),
43+
`order` = VALUES(`order`),
44+
content = VALUES(content),
45+
active = 1
46+
"""
47+
48+
49+
def normalize_part(value: str) -> str:
50+
return str(value or "").strip().lower().replace("_", "-")
51+
52+
53+
def normalize_lesson_key(discipline: str, slug: str) -> str:
54+
return f"{normalize_part(discipline)}:{normalize_part(slug)}"
55+
56+
57+
def strip_frontmatter(text: str) -> str:
58+
if not text.startswith("---"):
59+
return text
60+
stripped, count = FRONTMATTER_RE.subn("", text, count=1)
61+
return stripped if count else text
62+
63+
64+
def lesson_json_path(discipline: str, slug: str, order: int) -> Path:
65+
return JSONS_DIR / discipline / f"{discipline}__{order:02d}__{slug}.json"
66+
67+
68+
def build_context_header(
69+
discipline: str,
70+
name: str,
71+
concepts: list,
72+
keywords: list,
73+
learning_objectives: list,
74+
) -> str:
75+
concepts_str = ", ".join(str(c) for c in concepts)
76+
keywords_str = ", ".join(str(k) for k in keywords)
77+
objectives_str = " ".join(str(o) for o in learning_objectives)
78+
return (
79+
f"[CONTEXTO DA AULA] Disciplina: {discipline} | Título: {name} | "
80+
f"Conceitos: {concepts_str} | Keywords: {keywords_str} | "
81+
f"Objetivos: {objectives_str}\n\n"
82+
)
83+
84+
85+
def chunk_text_words(text: str, chunk_words: int = CHUNK_WORDS, overlap: int = CHUNK_OVERLAP) -> list[str]:
86+
"""Divide texto em janelas de ~chunk_words palavras com overlap (espelha database.py)."""
87+
words = text.split()
88+
if not words:
89+
return []
90+
chunks: list[str] = []
91+
start = 0
92+
while start < len(words):
93+
end = min(start + chunk_words, len(words))
94+
chunks.append(" ".join(words[start:end]))
95+
if end == len(words):
96+
break
97+
start += chunk_words - overlap
98+
return chunks
99+
100+
101+
def build_enriched_content(
102+
discipline: str,
103+
name: str,
104+
concepts: list,
105+
keywords: list,
106+
learning_objectives: list,
107+
raw_content: str,
108+
) -> str:
109+
header = build_context_header(discipline, name, concepts, keywords, learning_objectives)
110+
body = strip_frontmatter(raw_content)
111+
if not body.strip():
112+
raise ValueError("conteúdo vazio após frontmatter")
113+
114+
text_chunks = chunk_text_words(body)
115+
if not text_chunks:
116+
raise ValueError("conteúdo sem palavras após frontmatter")
117+
118+
enriched = [f"{header}{chunk}" for chunk in text_chunks]
119+
return CHUNK_SEPARATOR.join(enriched)
120+
121+
122+
def _as_str_list(value: object, field: str) -> list:
123+
if value is None:
124+
return []
125+
if not isinstance(value, list):
126+
raise ValueError(f"campo {field} deve ser array")
127+
return value
128+
129+
130+
def load_lesson_json(discipline: str, slug: str, order: int) -> str:
131+
path = lesson_json_path(discipline, slug, order)
132+
if not path.is_file():
133+
raise FileNotFoundError(f"JSON ausente: {path.relative_to(ROOT)}")
134+
135+
with path.open(encoding="utf-8") as fh:
136+
data = json.load(fh)
137+
if not isinstance(data, dict):
138+
raise ValueError(f"JSON inválido (objeto esperado): {path.relative_to(ROOT)}")
139+
140+
discipline_val = str(data.get("discipline", "")).strip()
141+
name = str(data.get("name", "")).strip()
142+
raw_content = data.get("content")
143+
144+
if not discipline_val:
145+
raise ValueError(f"campo discipline vazio: {path.relative_to(ROOT)}")
146+
if not name:
147+
raise ValueError(f"campo name vazio: {path.relative_to(ROOT)}")
148+
if not isinstance(raw_content, str) or not raw_content.strip():
149+
raise ValueError(f"campo content vazio: {path.relative_to(ROOT)}")
150+
151+
concepts = _as_str_list(data.get("concepts"), "concepts")
152+
keywords = _as_str_list(data.get("keywords"), "keywords")
153+
learning_objectives = _as_str_list(data.get("learning_objectives"), "learning_objectives")
154+
155+
return build_enriched_content(
156+
discipline_val,
157+
name,
158+
concepts,
159+
keywords,
160+
learning_objectives,
161+
raw_content,
162+
)
163+
164+
165+
def load_lessons() -> list[dict]:
166+
with LESSONS_PATH.open(encoding="utf-8") as fh:
167+
data = json.load(fh)
168+
if not isinstance(data, list):
169+
raise ValueError("lessons.json deve ser um array")
170+
return data
171+
172+
173+
def db_connect() -> pymysql.connections.Connection:
174+
host = os.environ.get("DB_HOST", "").strip()
175+
name = os.environ.get("DB_NAME", "").strip()
176+
user = os.environ.get("DB_USER", "").strip()
177+
password = os.environ.get("DB_PASSWORD", "")
178+
port = int(os.environ.get("DB_PORT", "3306") or "3306")
179+
180+
missing = [k for k, v in [("DB_HOST", host), ("DB_NAME", name), ("DB_USER", user)] if not v]
181+
if missing:
182+
raise RuntimeError(f"variáveis de ambiente ausentes: {', '.join(missing)}")
183+
184+
return pymysql.connect(
185+
host=host,
186+
port=port,
187+
database=name,
188+
user=user,
189+
password=password,
190+
charset="utf8mb4",
191+
autocommit=False,
192+
)
193+
194+
195+
def write_report(report: dict) -> None:
196+
REPORT_DIR.mkdir(parents=True, exist_ok=True)
197+
with REPORT_PATH.open("w", encoding="utf-8") as fh:
198+
json.dump(report, fh, indent=2, ensure_ascii=False)
199+
fh.write("\n")
200+
print(f"Relatório: {REPORT_PATH}")
201+
202+
203+
def main() -> int:
204+
errors: list[str] = []
205+
processed_keys: list[str] = []
206+
upserted_count = 0
207+
deactivated_count = 0
208+
209+
report_base = {
210+
"timestamp": datetime.now(timezone.utc).isoformat(),
211+
"success": False,
212+
"upserted_count": 0,
213+
"deactivated_count": 0,
214+
"errors": errors,
215+
"processed_keys": processed_keys,
216+
}
217+
218+
try:
219+
lessons = load_lessons()
220+
except Exception as exc:
221+
errors.append(str(exc))
222+
write_report({**report_base, "errors": errors})
223+
print(f"Ingest falhou: {exc}", file=sys.stderr)
224+
return 1
225+
226+
rows: list[tuple[str, str, str, int, str]] = []
227+
228+
for i, lesson in enumerate(lessons):
229+
prefix = f"lessons[{i}]"
230+
if not isinstance(lesson, dict):
231+
errors.append(f"{prefix}: entrada inválida")
232+
continue
233+
234+
discipline_raw = str(lesson.get("discipline", "")).strip()
235+
slug_raw = str(lesson.get("slug", "")).strip()
236+
title = str(lesson.get("title", "")).strip()
237+
order = lesson.get("order")
238+
file_rel = str(lesson.get("file", "")).replace("\\", "/").strip()
239+
240+
if not discipline_raw or not slug_raw or not title or order is None or not file_rel:
241+
errors.append(f"{prefix}: campos obrigatórios ausentes")
242+
continue
243+
244+
discipline = normalize_part(discipline_raw)
245+
slug = normalize_part(slug_raw)
246+
key = normalize_lesson_key(discipline, slug)
247+
248+
try:
249+
order_int = int(order)
250+
except (TypeError, ValueError):
251+
errors.append(f"{prefix} ({key}): order inválido: {order!r}")
252+
continue
253+
254+
try:
255+
content = load_lesson_json(discipline, slug, order_int)
256+
except Exception as exc:
257+
errors.append(f"{prefix} ({key}): {exc}")
258+
continue
259+
260+
rows.append((discipline, slug, title, order_int, content))
261+
processed_keys.append(key)
262+
263+
if errors:
264+
write_report({**report_base, "errors": errors, "processed_keys": processed_keys})
265+
print(f"Ingest abortado: {len(errors)} erro(s) em lições.", file=sys.stderr)
266+
for err in errors:
267+
print(f" ERROR: {err}", file=sys.stderr)
268+
return 1
269+
270+
if not rows:
271+
errors.append("nenhuma lição processada — abortando sem desativar registros no banco")
272+
write_report({**report_base, "errors": errors})
273+
print(errors[0], file=sys.stderr)
274+
return 1
275+
276+
processed_keys.sort()
277+
278+
try:
279+
conn = db_connect()
280+
except Exception as exc:
281+
errors.append(f"conexão MySQL: {exc}")
282+
write_report({**report_base, "errors": errors, "processed_keys": processed_keys})
283+
print(f"Ingest falhou: {exc}", file=sys.stderr)
284+
return 1
285+
286+
try:
287+
with conn:
288+
with conn.cursor() as cursor:
289+
for row in rows:
290+
cursor.execute(UPSERT_SQL, row)
291+
upserted_count += 1
292+
293+
pair_placeholders = ",".join(["(%s, %s)"] * len(rows))
294+
deactivate_params: list[str] = []
295+
for discipline, slug, *_ in rows:
296+
deactivate_params.extend([discipline, slug])
297+
298+
deactivate_sql = (
299+
"UPDATE knowledge SET active = 0 "
300+
"WHERE active = 1 "
301+
f"AND (discipline, slug) NOT IN ({pair_placeholders})"
302+
)
303+
cursor.execute(deactivate_sql, deactivate_params)
304+
deactivated_count = cursor.rowcount
305+
306+
conn.commit()
307+
except Exception as exc:
308+
conn.rollback()
309+
errors.append(str(exc))
310+
write_report(
311+
{
312+
**report_base,
313+
"errors": errors,
314+
"processed_keys": processed_keys,
315+
"upserted_count": 0,
316+
"deactivated_count": 0,
317+
}
318+
)
319+
print(f"Ingest falhou (rollback): {exc}", file=sys.stderr)
320+
return 1
321+
322+
report = {
323+
"timestamp": datetime.now(timezone.utc).isoformat(),
324+
"success": True,
325+
"upserted_count": upserted_count,
326+
"deactivated_count": deactivated_count,
327+
"errors": [],
328+
"processed_keys": processed_keys,
329+
}
330+
write_report(report)
331+
print(
332+
f"Ingest OK: {upserted_count} upsert(s), "
333+
f"{deactivated_count} desativado(s), {len(processed_keys)} chave(s)."
334+
)
335+
return 0
336+
337+
338+
if __name__ == "__main__":
339+
sys.exit(main())

0 commit comments

Comments
 (0)