Terminal / tools /notion_tool.py
Pulka
sync: Gap A/B/C — P24 recovery chains + TOOL_SUBSTITUTE_MAP + selfLearning threshold (commit 0fa9aa40)
331397a verified
Raw
History Blame Contribute Delete
14.8 kB
"""
backend/tools/notion_tool.py — P24-F2: Notion read/write/search/append.
Permette all'agente di leggere, scrivere e cercare in Notion.
Richiede NOTION_TOKEN env var (Integration Token da notion.so/my-integrations).
Operazioni:
search — cerca pagine/database per testo
read — legge contenuto di una pagina (restituisce markdown-like)
write — crea nuova pagina sotto un genitore
append — aggiunge testo/markdown a pagina esistente
"""
from __future__ import annotations
import logging
import os
from typing import Any
import httpx
_logger = logging.getLogger("agente_ai.tools.notion_tool")
# Token resolution: env var → vault (fallback) → empty
def _resolve_notion_token() -> str:
"""Legge NOTION_TOKEN da env var o dal vault crittografato del backend."""
# 1. Env var diretta (Railway secrets, HF Spaces secrets, .env locale)
token = os.getenv("NOTION_TOKEN", "")
if token:
return token
# 2. Fallback: vault crittografato (persistente tra i restart)
try:
import json as _j
from pathlib import Path as _P
import base64 as _b64, hashlib as _h, hmac as _hmac_m
vault_path_str = os.getenv("VAULT_PATH", "/tmp/.vault_data/vault_secrets.json")
_vp = _P(vault_path_str)
if _vp.exists():
data = _j.loads(_vp.read_text())
raw_enc = data.get("notion_token", "")
if raw_enc:
raw_key = os.getenv("VAULT_KEY", "").encode() or b""
if raw_key:
_vk = _h.sha256(raw_key).digest()
raw = _b64.b64decode(raw_enc)
sig, ct = raw[:32], raw[32:]
if _hmac_m.compare_digest(sig, _hmac_m.new(_vk, ct, _h.sha256).digest()):
ks = b""
for i in range((len(ct) // 32) + 1):
ks += _h.sha256(_vk + i.to_bytes(4, "big")).digest()
return bytes(a ^ b for a, b in zip(ct, ks[:len(ct)])).decode("utf-8")
except Exception: # noqa: BLE001
pass
return ""
_NOTION_TOKEN: str = _resolve_notion_token()
_BASE_URL = "https://api.notion.com/v1"
_NOTION_VER = "2022-06-28"
_TIMEOUT = 15.0
_MAX_TEXT = 8000 # max caratteri estratti da una pagina
def _hdrs() -> dict[str, str]:
return {
"Authorization": f"Bearer {_NOTION_TOKEN}",
"Notion-Version": _NOTION_VER,
"Content-Type": "application/json",
}
# ── Parsing blocchi → testo ────────────────────────────────────────────────────
def _blocks_to_text(blocks: list[dict]) -> str:
lines: list[str] = []
for b in blocks:
btype = b.get("type", "")
obj = b.get(btype, {})
rich = obj.get("rich_text", [])
text = "".join(r.get("plain_text", "") for r in rich)
if btype == "code":
lang = obj.get("language", "")
lines.append(f"```{lang}\n{text}\n```")
elif btype == "heading_1":
lines.append(f"# {text}")
elif btype == "heading_2":
lines.append(f"## {text}")
elif btype == "heading_3":
lines.append(f"### {text}")
elif btype == "bulleted_list_item":
lines.append(f"• {text}")
elif btype == "numbered_list_item":
lines.append(f"- {text}")
elif btype == "to_do":
mark = "✅" if obj.get("checked") else "☐"
lines.append(f"{mark} {text}")
elif btype == "divider":
lines.append("---")
elif text:
lines.append(text)
return "\n".join(lines)
# ── Markdown → blocchi Notion ──────────────────────────────────────────────────
def _text_to_blocks(text: str) -> list[dict[str, Any]]:
blocks: list[dict[str, Any]] = []
in_code = False
code_buf: list[str] = []
code_lang = "plain text"
def _rt(s: str) -> list[dict]:
return [{"type": "text", "text": {"content": s[:2000]}}]
for line in text.split("\n"):
if line.startswith("```"):
if not in_code:
in_code = True
code_lang = line[3:].strip() or "plain text"
code_buf = []
else:
in_code = False
blocks.append({"type": "code", "code": {
"language": code_lang,
"rich_text": _rt("\n".join(code_buf)),
}})
continue
if in_code:
code_buf.append(line)
continue
if line.startswith("# "):
blocks.append({"type": "heading_1", "heading_1": {"rich_text": _rt(line[2:])}})
elif line.startswith("## "):
blocks.append({"type": "heading_2", "heading_2": {"rich_text": _rt(line[3:])}})
elif line.startswith("### "):
blocks.append({"type": "heading_3", "heading_3": {"rich_text": _rt(line[4:])}})
elif line.startswith(("- ", "• ")):
blocks.append({"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": _rt(line[2:])}})
elif line.strip() in ("---", "***", "___"):
blocks.append({"type": "divider", "divider": {}})
elif not line.strip():
blocks.append({"type": "paragraph", "paragraph": {"rich_text": []}})
else:
blocks.append({"type": "paragraph", "paragraph": {"rich_text": _rt(line)}})
return blocks[:100] # Notion: max 100 blocchi per richiesta
# ── Funzione principale ────────────────────────────────────────────────────────
async def notion_rw(
action: str,
query: str = "",
page_id: str = "",
parent_id: str = "",
title: str = "",
content: str = "",
max_results: int = 5,
) -> dict[str, Any]:
"""
Interagisce con Notion (leggi / scrivi / cerca / appendi).
action = "search" → cerca pagine per testo (query obbligatorio)
action = "read" → legge contenuto pagina (page_id obbligatorio)
action = "write" → crea nuova pagina (parent_id + title; content opzionale)
action = "append" → aggiunge contenuto a pagina esistente (page_id + content)
Richiede NOTION_TOKEN nel vault del backend.
"""
if not _NOTION_TOKEN:
return {
"ok": False,
"error": (
"NOTION_TOKEN non configurato. "
"Aggiungilo come NOTION_TOKEN nei secrets Railway/HF Spaces, "
"oppure salvalo nel vault tramite /api/vault (chiave: notion_token)."
),
}
action = action.lower().strip()
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, base_url=_BASE_URL) as c:
# ── SEARCH ────────────────────────────────────────────────────────
if action == "search":
if not query:
return {"ok": False, "error": "query è obbligatorio per action=search"}
r = await c.post("/search", headers=_hdrs(),
json={"query": query, "page_size": min(max(1, max_results), 20)})
if r.status_code != 200:
return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"}
items = []
for obj in r.json().get("results", []):
otype = obj.get("object", "")
t = ""
if otype == "page":
for v in obj.get("properties", {}).values():
if v.get("type") == "title":
t = "".join(x.get("plain_text","") for x in v.get("title",[]))
break
elif otype == "database":
t = "".join(x.get("plain_text","") for x in obj.get("title",[]))
items.append({
"id": obj.get("id","").replace("-",""),
"title": t or "(senza titolo)",
"type": otype,
"url": obj.get("url",""),
"last_edited": obj.get("last_edited_time",""),
})
return {"ok": True, "action": "search", "query": query,
"results": items, "count": len(items)}
# ── READ ──────────────────────────────────────────────────────────
elif action == "read":
if not page_id:
return {"ok": False, "error": "page_id è obbligatorio per action=read"}
pid = page_id.replace("-","")
meta = await c.get(f"/pages/{pid}", headers=_hdrs())
if meta.status_code != 200:
return {"ok": False, "error": f"Pagina non trovata o accesso negato ({meta.status_code})"}
m = meta.json()
t = ""
for v in m.get("properties", {}).values():
if v.get("type") == "title":
t = "".join(x.get("plain_text","") for x in v.get("title",[]))
break
blk_r = await c.get(f"/blocks/{pid}/children",
headers=_hdrs(), params={"page_size": 100})
blocks = blk_r.json().get("results",[]) if blk_r.status_code == 200 else []
return {
"ok": True, "action": "read",
"page_id": pid, "title": t,
"url": m.get("url",""),
"last_edited": m.get("last_edited_time",""),
"content": _blocks_to_text(blocks)[:_MAX_TEXT],
"block_count": len(blocks),
}
# ── WRITE ─────────────────────────────────────────────────────────
elif action == "write":
if not parent_id:
return {"ok": False, "error": "parent_id obbligatorio per action=write"}
if not title:
return {"ok": False, "error": "title obbligatorio per action=write"}
pid = parent_id.replace("-","")
blocks = _text_to_blocks(content) if content else []
payload: dict[str, Any] = {
"parent": {"page_id": pid},
"properties": {"title": {"title": [{"type":"text","text":{"content":title[:2000]}}]}},
"children": blocks,
}
r = await c.post("/pages", headers=_hdrs(), json=payload)
if r.status_code not in (200, 201):
return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"}
created = r.json()
return {
"ok": True, "action": "write",
"page_id": created.get("id","").replace("-",""),
"url": created.get("url",""),
"title": title,
"blocks_written": len(blocks),
"message": f"Pagina '{title}' creata su Notion",
}
# ── APPEND ────────────────────────────────────────────────────────
elif action == "append":
if not page_id:
return {"ok": False, "error": "page_id obbligatorio per action=append"}
if not content:
return {"ok": False, "error": "content obbligatorio per action=append"}
pid = page_id.replace("-","")
blocks = _text_to_blocks(content)
r = await c.patch(f"/blocks/{pid}/children",
headers=_hdrs(), json={"children": blocks})
if r.status_code != 200:
return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"}
return {
"ok": True, "action": "append",
"page_id": pid,
"blocks_appended": len(blocks),
"message": f"{len(blocks)} blocchi aggiunti alla pagina",
}
else:
return {"ok": False, "error": f"action non valida: '{action}'. Valori: search, read, write, append"}
except httpx.TimeoutException:
return {"ok": False, "error": f"Timeout ({_TIMEOUT:.0f}s) — Notion API non ha risposto"}
except Exception as exc: # noqa: BLE001
_logger.warning("[notion_tool] %s", exc)
return {"ok": False, "error": str(exc)[:300]}
# ── Tool registry descriptor ───────────────────────────────────────────────────
TOOL_DESCRIPTOR = {
"name": "notion_rw",
"description": (
"Leggi, scrivi e cerca su Notion. "
"action=search: cerca pagine per query testuale. "
"action=read: legge contenuto pagina (page_id). "
"action=write: crea nuova pagina (parent_id + title + content opzionale). "
"action=append: aggiunge testo/markdown a pagina esistente (page_id + content). "
"Richiede NOTION_TOKEN configurato nel backend."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["search","read","write","append"],
"description": "Operazione da eseguire"},
"query": {"type": "string", "description": "Testo di ricerca (per search)"},
"page_id": {"type": "string", "description": "ID pagina Notion (per read/append)"},
"parent_id": {"type": "string", "description": "ID pagina genitore (per write)"},
"title": {"type": "string", "description": "Titolo nuova pagina (per write)"},
"content": {"type": "string", "description": "Testo/Markdown da scrivere o appendere"},
"max_results": {"type": "integer", "description": "Max risultati search (default 5, max 20)"},
},
"required": ["action"],
},
"fn": notion_rw,
}