feat(digest): digest hebdo vers ObsiGate via MCP, sur approbation (issue #13)
app/digest.py: format markdown (journal 7 j + focus), client MCP (initialize/tools/list/tools/call, réponses JSON ou SSE), gabarit d'arguments JSON configurable, webhook générique. Page /digest: statut, sonde des outils, envoi en un clic. Planificateur: compose + notifie (kv digest_pending), ne livre JAMAIS seul.
This commit is contained in:
+35
-1
@@ -51,6 +51,23 @@ Environment variables:
|
|||||||
an issue in KANBANTE_CAPTURE_REPO.
|
an issue in KANBANTE_CAPTURE_REPO.
|
||||||
Uses KANBANTE_NTFY_URL/_TOKEN as the
|
Uses KANBANTE_NTFY_URL/_TOKEN as the
|
||||||
server. Empty (default) = no listener.
|
server. Empty (default) = no listener.
|
||||||
|
KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is
|
||||||
|
composed (and ntfy-notified) on
|
||||||
|
KANBANTE_DIGEST_WEEKDAY. The digest is
|
||||||
|
NEVER auto-delivered: it waits for a
|
||||||
|
one-click approval on /digest.
|
||||||
|
KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0).
|
||||||
|
KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest
|
||||||
|
as JSON {title, markdown, week}.
|
||||||
|
KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the
|
||||||
|
digest is delivered by calling a
|
||||||
|
note-creation tool on it (client MCP).
|
||||||
|
KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP.
|
||||||
|
KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call
|
||||||
|
(see /digest for the live tool list).
|
||||||
|
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
|
||||||
|
"{title}" and "{content}" placeholders
|
||||||
|
are substituted (JSON-escaped).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -59,7 +76,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.7.0"
|
APP_VERSION = "0.8.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -118,6 +135,13 @@ class Settings:
|
|||||||
capture_repo: str = ""
|
capture_repo: str = ""
|
||||||
capture_token: str = ""
|
capture_token: str = ""
|
||||||
capture_ntfy_topic: str = ""
|
capture_ntfy_topic: str = ""
|
||||||
|
digest_time: str = ""
|
||||||
|
digest_weekday: int = 0
|
||||||
|
digest_webhook_url: str = ""
|
||||||
|
obsigate_mcp_url: str = ""
|
||||||
|
obsigate_mcp_token: str = ""
|
||||||
|
obsigate_mcp_tool: str = ""
|
||||||
|
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -150,4 +174,14 @@ def get_settings() -> Settings:
|
|||||||
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
|
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
|
||||||
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
|
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
|
||||||
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
|
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
|
||||||
|
digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""),
|
||||||
|
digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0),
|
||||||
|
digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""),
|
||||||
|
obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""),
|
||||||
|
obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""),
|
||||||
|
obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""),
|
||||||
|
obsigate_mcp_args=os.getenv(
|
||||||
|
"KANBANTE_OBSIGATE_MCP_ARGS",
|
||||||
|
'{"title": "{title}", "content": "{content}"}',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
"""Weekly digest (roadmap #13): the week in Kanbante, as a note.
|
||||||
|
|
||||||
|
Composes a markdown digest from the event journal (last 7 days) and
|
||||||
|
the current focus snapshot, then — ONLY after a human approves on
|
||||||
|
/digest (standing rule: cross-app writes need approval) — delivers
|
||||||
|
it to ObsiGate through its MCP server (Kanbante as MCP *client*:
|
||||||
|
initialize → tools/call) or to a generic JSON webhook.
|
||||||
|
|
||||||
|
The ObsiGate tool contract is configurable (URL, tool name, JSON
|
||||||
|
argument template with {title}/{content} placeholders) because the
|
||||||
|
exact tool names of the target server are an operator decision, not
|
||||||
|
something to hardcode blindly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("kanbante.digest")
|
||||||
|
|
||||||
|
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
|
||||||
|
|
||||||
|
def format_digest_markdown(
|
||||||
|
week_label: str,
|
||||||
|
closed_items: list[dict],
|
||||||
|
created_count: int,
|
||||||
|
moved_count: int,
|
||||||
|
focus_groups: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""Render the weekly digest as markdown (Obsidian-friendly)."""
|
||||||
|
lines = [f"# 🧠 Kanbante — semaine du {week_label}", ""]
|
||||||
|
lines.append(f"## ✅ Livré ({len(closed_items)})")
|
||||||
|
if closed_items:
|
||||||
|
for item in closed_items:
|
||||||
|
title = item.get("title") or ""
|
||||||
|
lines.append(
|
||||||
|
f"- [{item['repo']}#{item['number']}]({item.get('html_url', '')})"
|
||||||
|
f" {title}".rstrip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- Rien n'a été fermé cette semaine.")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 🚧 En cours")
|
||||||
|
total_focus = sum(len(g["cards"]) for g in focus_groups)
|
||||||
|
if total_focus:
|
||||||
|
for group in focus_groups:
|
||||||
|
lines.append(f"### {group['full_name']}")
|
||||||
|
for card in group["cards"]:
|
||||||
|
issue = card["issue"]
|
||||||
|
lines.append(
|
||||||
|
f"- #{issue['number']} {issue.get('title', '')}"
|
||||||
|
f" — *{card['column']}*"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- Aucune carte en cours, en QA ou en révision.")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 📈 Activité de la semaine")
|
||||||
|
lines.append(f"- Issues créées : {created_count}")
|
||||||
|
lines.append(f"- Déplacements de cartes : {moved_count}")
|
||||||
|
lines.append(f"- Issues fermées : {len(closed_items)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("*Digest composé par Kanbante à partir de son journal d'événements.*")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_mcp_args(template: str, title: str, content: str) -> dict:
|
||||||
|
"""Substitute {title}/{content} in a JSON template, safely."""
|
||||||
|
def esc(value: str) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False)[1:-1]
|
||||||
|
|
||||||
|
rendered = template.replace("{title}", esc(title)).replace(
|
||||||
|
"{content}", esc(content)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
args = json.loads(rendered)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"KANBANTE_OBSIGATE_MCP_ARGS is not valid JSON: {exc}")
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
raise ValueError("KANBANTE_OBSIGATE_MCP_ARGS must be a JSON object")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
async def send_webhook(url: str, title: str, markdown: str, week: str) -> tuple[bool, str]:
|
||||||
|
"""POST the digest as JSON to a generic webhook."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url, json={"title": title, "markdown": markdown, "week": week}
|
||||||
|
)
|
||||||
|
if response.status_code < 300:
|
||||||
|
return True, f"webhook HTTP {response.status_code}"
|
||||||
|
return False, f"webhook HTTP {response.status_code}: {response.text[:200]}"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"webhook error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_headers(token: str) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mcp_body(response: httpx.Response) -> dict:
|
||||||
|
"""MCP streamable HTTP answers JSON or an SSE frame — accept both."""
|
||||||
|
text = response.text.strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
if text.startswith("{"):
|
||||||
|
return json.loads(text)
|
||||||
|
payload: dict = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
chunk = line[5:].strip()
|
||||||
|
if chunk.startswith("{"):
|
||||||
|
payload = json.loads(chunk)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _mcp_rpc(client: httpx.AsyncClient, url: str, token: str,
|
||||||
|
method: str, params: dict, req_id: int) -> dict:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
|
||||||
|
headers=_mcp_headers(token),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = _parse_mcp_body(response)
|
||||||
|
if "error" in payload:
|
||||||
|
err = payload["error"] or {}
|
||||||
|
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
|
||||||
|
return payload.get("result") or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||||
|
await _mcp_rpc(
|
||||||
|
client, url, token, "initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "kanbante", "version": "0.8.0"},
|
||||||
|
},
|
||||||
|
req_id=1,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
url,
|
||||||
|
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||||
|
headers=_mcp_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_list_tools(url: str, token: str) -> tuple[bool, list[str] | str]:
|
||||||
|
"""(True, [tool names]) or (False, error message)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
await _mcp_session(client, url, token)
|
||||||
|
result = await _mcp_rpc(client, url, token, "tools/list", {}, req_id=2)
|
||||||
|
return True, [t.get("name", "?") for t in result.get("tools", [])]
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"MCP tools/list failed: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def mcp_call_tool(
|
||||||
|
url: str, token: str, tool: str, arguments: dict
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Call one tool; (True, short result) or (False, error message)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
await _mcp_session(client, url, token)
|
||||||
|
result = await _mcp_rpc(
|
||||||
|
client, url, token, "tools/call",
|
||||||
|
{"name": tool, "arguments": arguments}, req_id=3,
|
||||||
|
)
|
||||||
|
if result.get("isError"):
|
||||||
|
return False, f"tool {tool} reported an error: {str(result)[:300]}"
|
||||||
|
texts = [
|
||||||
|
block.get("text", "")
|
||||||
|
for block in result.get("content", [])
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text"
|
||||||
|
]
|
||||||
|
return True, (" ".join(texts)[:300] or f"tool {tool} OK")
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"MCP tools/call failed: {exc}"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Digest hebdo — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>🧠 Digest hebdo → ObsiGate</h1>
|
||||||
|
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
|
||||||
|
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
|
||||||
|
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
|
||||||
|
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if banner %}
|
||||||
|
<div class="briefing-card"><p>{{ banner }}</p></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tools is not none %}
|
||||||
|
<div class="briefing-card">
|
||||||
|
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
|
||||||
|
{% if tools %}
|
||||||
|
<ul>
|
||||||
|
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>Choisis l'outil de création de note, puis définis <code>KANBANTE_OBSIGATE_MCP_TOOL</code> (et au besoin <code>KANBANTE_OBSIGATE_MCP_ARGS</code>, gabarit JSON avec <code>{title}</code> et <code>{content}</code>).</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Le serveur répond, mais n'expose aucun outil.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="briefing-card">
|
||||||
|
<h2>📤 Livraison</h2>
|
||||||
|
<p>
|
||||||
|
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
|
||||||
|
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/digest/probe" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/digest/send" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="target" value="obsigate">
|
||||||
|
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/digest/send" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="target" value="webhook">
|
||||||
|
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="briefing-card">
|
||||||
|
<h2>👀 Aperçu du digest de la semaine</h2>
|
||||||
|
{% if pending %}
|
||||||
|
<pre class="capture-pre">{{ pending }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p>Aucun digest en attente. L'aperçu exact est composé au moment de l'envoi (boutons ci-dessus) à partir du journal des 7 derniers jours et de la vue Focus.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation</span>
|
||||||
|
</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user