Spaces:
Running
Running
File size: 15,017 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | """
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 ""
_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]:
# Risoluzione dinamica del token per riflettere aggiornamenti del Vault senza restart
token = _resolve_notion_token()
return {
"Authorization": f"Bearer {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 o nelle env vars.
"""
# Verifica token all'inizio della chiamata
if not _resolve_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,
}
|