Spaces:
Running
Running
Pulka
sync: Gap A/B/C — P24 recovery chains + TOOL_SUBSTITUTE_MAP + selfLearning threshold (commit 0fa9aa40)
331397a verified | """ | |
| 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, | |
| } | |