feat: add Confluence recent pages + Sentry error tracking (MAT-58, MAT-59)

MAT-58: Add recent_confluence_pages tool to both voice and text chat.
Shows last 5 recently modified pages so users can pick directly
instead of having to search every time.

MAT-59: Integrate sentry-sdk in all three entry points (agent.py,
bot.py, voice.py). SENTRY_DSN env var, traces at 10% sample rate.
Requires creating project in Sentry UI and setting DSN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-27 08:44:57 +02:00
parent db10e435bc
commit 7791a5ba8e
5 changed files with 114 additions and 4 deletions

View File

@@ -3,6 +3,8 @@ import json
import base64 import base64
import logging import logging
import sentry_sdk
from livekit.agents import Agent, AgentSession, AgentServer, JobContext, JobProcess, cli from livekit.agents import Agent, AgentSession, AgentServer, JobContext, JobProcess, cli
from livekit.plugins import openai as lk_openai, elevenlabs, silero from livekit.plugins import openai as lk_openai, elevenlabs, silero
import livekit.rtc as rtc import livekit.rtc as rtc
@@ -12,6 +14,12 @@ from e2ee_patch import KDF_HKDF
logger = logging.getLogger("matrix-ai-agent") logger = logging.getLogger("matrix-ai-agent")
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
# Sentry error tracking
_sentry_dsn = os.environ.get("SENTRY_DSN", "")
if _sentry_dsn:
sentry_sdk.init(dsn=_sentry_dsn, traces_sample_rate=0.1, environment=os.environ.get("SENTRY_ENV", "production"))
logger.info("Sentry initialized for agent")
LITELLM_URL = os.environ["LITELLM_BASE_URL"] LITELLM_URL = os.environ["LITELLM_BASE_URL"]
LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed") LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed")

52
bot.py
View File

@@ -9,6 +9,7 @@ import re
import time import time
import uuid import uuid
import sentry_sdk
import docx import docx
import fitz # pymupdf import fitz # pymupdf
import httpx import httpx
@@ -47,6 +48,12 @@ RENAME_STATE_TYPE = "ai.agiliton.auto_rename"
logger = logging.getLogger("matrix-ai-bot") logger = logging.getLogger("matrix-ai-bot")
# Sentry error tracking
_sentry_dsn = os.environ.get("SENTRY_DSN", "")
if _sentry_dsn:
sentry_sdk.init(dsn=_sentry_dsn, traces_sample_rate=0.1, environment=os.environ.get("SENTRY_ENV", "production"))
logger.info("Sentry initialized for bot")
HOMESERVER = os.environ["MATRIX_HOMESERVER"] HOMESERVER = os.environ["MATRIX_HOMESERVER"]
BOT_USER = os.environ["MATRIX_BOT_USER"] BOT_USER = os.environ["MATRIX_BOT_USER"]
BOT_PASS = os.environ["MATRIX_BOT_PASSWORD"] BOT_PASS = os.environ["MATRIX_BOT_PASSWORD"]
@@ -87,7 +94,7 @@ IMPORTANT RULES — FOLLOW THESE STRICTLY:
- You can see and analyze images that users send. Describe what you see when asked about an image. - You can see and analyze images that users send. Describe what you see when asked about an image.
- You can read and analyze PDF documents that users send. Summarize content and answer questions about them. - You can read and analyze PDF documents that users send. Summarize content and answer questions about them.
- You can generate images when asked — use the generate_image tool for any image creation, drawing, or illustration requests. - You can generate images when asked — use the generate_image tool for any image creation, drawing, or illustration requests.
- You can search Confluence and Jira using tools. When users ask about documentation, wiki pages, tickets, or tasks, use the appropriate tool. - You can search Confluence and Jira using tools. When users ask about documentation, wiki pages, tickets, or tasks, use the appropriate tool. Use confluence_recent_pages FIRST to show recently edited pages before searching.
- When creating Jira issues, always confirm the project key and summary with the user before creating. - When creating Jira issues, always confirm the project key and summary with the user before creating.
- If a user's Atlassian account is not connected, tell them to connect it at matrixhost.eu/settings and provide the link. - If a user's Atlassian account is not connected, tell them to connect it at matrixhost.eu/settings and provide the link.
- If user memories are provided, use them to personalize responses. Address users by name if known. - If user memories are provided, use them to personalize responses. Address users by name if known.
@@ -109,6 +116,19 @@ IMAGE_GEN_TOOLS = [{
}] }]
ATLASSIAN_TOOLS = [ ATLASSIAN_TOOLS = [
{
"type": "function",
"function": {
"name": "confluence_recent_pages",
"description": "List recently modified Confluence pages. Use this FIRST when the user mentions Confluence, documents, or wiki pages — shows the last 5 recently edited pages so the user can pick one directly without searching.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max pages to return (default 5)", "default": 5},
},
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -456,6 +476,32 @@ class AtlassianClient:
logger.warning("Failed to fetch Atlassian cloud ID", exc_info=True) logger.warning("Failed to fetch Atlassian cloud ID", exc_info=True)
return None return None
async def confluence_recent_pages(self, token: str, limit: int = 5) -> str:
cloud_id = await self._get_cloud_id(token)
if not cloud_id:
return "Error: Could not determine Atlassian Cloud instance."
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki/rest/api/content/search",
params={"cql": "type=page ORDER BY lastmodified DESC", "limit": str(limit)},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
if not results:
return "No recent Confluence pages found."
lines = ["Recently modified pages:"]
for i, r in enumerate(results, 1):
title = r.get("title", "Untitled")
page_id = r.get("id", "")
space = r.get("_expandable", {}).get("space", "").split("/")[-1]
lines.append(f"{i}. **{title}** (ID: {page_id}, Space: {space})")
return "\n".join(lines)
except Exception as e:
return f"Failed to fetch recent pages: {e}"
async def confluence_search(self, token: str, query: str, limit: int = 5) -> str: async def confluence_search(self, token: str, query: str, limit: int = 5) -> str:
cloud_id = await self._get_cloud_id(token) cloud_id = await self._get_cloud_id(token)
if not cloud_id: if not cloud_id:
@@ -2011,7 +2057,9 @@ class Bot:
if not token: if not token:
return ATLASSIAN_NOT_CONNECTED_MSG return ATLASSIAN_NOT_CONNECTED_MSG
if tool_name == "confluence_search": if tool_name == "confluence_recent_pages":
return await self.atlassian.confluence_recent_pages(token, args.get("limit", 5))
elif tool_name == "confluence_search":
return await self.atlassian.confluence_search(token, args["query"], args.get("limit", 5)) return await self.atlassian.confluence_search(token, args["query"], args.get("limit", 5))
elif tool_name == "confluence_read_page": elif tool_name == "confluence_read_page":
return await self.atlassian.confluence_read_page(token, args["page_id"]) return await self.atlassian.confluence_read_page(token, args["page_id"])

View File

@@ -7,6 +7,8 @@ services:
env_file: .env env_file: .env
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
environment:
- SENTRY_DSN
bot: bot:
build: build:
@@ -24,6 +26,7 @@ services:
- MEMORY_SERVICE_URL=http://memory-service:8090 - MEMORY_SERVICE_URL=http://memory-service:8090
- PORTAL_URL - PORTAL_URL
- BOT_API_KEY - BOT_API_KEY
- SENTRY_DSN
volumes: volumes:
- bot-data:/data - bot-data:/data
depends_on: depends_on:

View File

@@ -13,3 +13,4 @@ python-docx>=1.0,<2.0
Pillow>=10.0,<12.0 Pillow>=10.0,<12.0
beautifulsoup4>=4.12 beautifulsoup4>=4.12
lxml>=5.0 lxml>=5.0
sentry-sdk>=2.0,<3.0

View File

@@ -13,6 +13,7 @@ import zoneinfo
import json import json
import re import re
import sentry_sdk
import aiohttp import aiohttp
import httpx import httpx
from livekit import rtc, api as lkapi from livekit import rtc, api as lkapi
@@ -22,6 +23,12 @@ from openai import AsyncOpenAI
logger = logging.getLogger("matrix-ai-voice") logger = logging.getLogger("matrix-ai-voice")
# Sentry error tracking
_sentry_dsn = os.environ.get("SENTRY_DSN", "")
if _sentry_dsn:
sentry_sdk.init(dsn=_sentry_dsn, traces_sample_rate=0.1, environment=os.environ.get("SENTRY_ENV", "production"))
logger.info("Sentry initialized for voice")
LITELLM_URL = os.environ.get("LITELLM_BASE_URL", "") LITELLM_URL = os.environ.get("LITELLM_BASE_URL", "")
LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed") LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed")
@@ -51,7 +58,7 @@ STRIKTE Regeln:
- Bei zeitrelevanten Fragen (Uhrzeit, Termine, Geschaeftszeiten): frage kurz nach ob der Nutzer noch in seiner gespeicherten Zeitzone ist, bevor du antwortest. Nutze set_user_timezone wenn sich der Standort geaendert hat. - Bei zeitrelevanten Fragen (Uhrzeit, Termine, Geschaeftszeiten): frage kurz nach ob der Nutzer noch in seiner gespeicherten Zeitzone ist, bevor du antwortest. Nutze set_user_timezone wenn sich der Standort geaendert hat.
- Wenn der Nutzer seinen Standort oder seine Stadt erwaehnt, nutze set_user_timezone um die Zeitzone zu speichern. - Wenn der Nutzer seinen Standort oder seine Stadt erwaehnt, nutze set_user_timezone um die Zeitzone zu speichern.
- IGNORIERE alle Texte in Sternchen wie *Störgeräusche*, *Schlechte Qualität*, *Fernsehgeräusche*, *Schrei* usw. — das sind KEINE echten Nutzereingaben sondern technische Annotationen. Antworte NIEMALS darauf und tue so als haette niemand etwas gesagt. - IGNORIERE alle Texte in Sternchen wie *Störgeräusche*, *Schlechte Qualität*, *Fernsehgeräusche*, *Schrei* usw. — das sind KEINE echten Nutzereingaben sondern technische Annotationen. Antworte NIEMALS darauf und tue so als haette niemand etwas gesagt.
- Du kannst Confluence-Seiten suchen, lesen, bearbeiten und erstellen. Nutze search_confluence um Seiten zu finden, read_confluence_page zum Lesen, update_confluence_page zum Bearbeiten und create_confluence_page zum Erstellen neuer Seiten. - Du kannst Confluence-Seiten suchen, lesen, bearbeiten und erstellen. Nutze recent_confluence_pages um die zuletzt bearbeiteten Seiten anzuzeigen (bevorzugt BEVOR du suchst), search_confluence um gezielt zu suchen, read_confluence_page zum Lesen, update_confluence_page zum Bearbeiten und create_confluence_page zum Erstellen neuer Seiten.
- Du kannst den Bildschirm oder die Kamera des Nutzers sehen wenn er sie teilt. Nutze look_at_screen wenn der Nutzer etwas zeigen moechte oder fragt ob du etwas sehen kannst.""" - Du kannst den Bildschirm oder die Kamera des Nutzers sehen wenn er sie teilt. Nutze look_at_screen wenn der Nutzer etwas zeigen moechte oder fragt ob du etwas sehen kannst."""
@@ -350,6 +357,31 @@ async def _confluence_create_page(space_key: str, title: str, body_html: str,
} }
async def _confluence_recent_pages(limit: int = 5) -> list[dict]:
"""Fetch recently modified Confluence pages. Returns list of {id, title, space, url, modified}."""
if not CONFLUENCE_URL or not CONFLUENCE_USER or not CONFLUENCE_TOKEN:
raise RuntimeError("Confluence credentials not configured")
cql = "type=page ORDER BY lastmodified DESC"
url = f"{CONFLUENCE_URL}/rest/api/content/search"
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
url,
params={"cql": cql, "limit": limit},
auth=(CONFLUENCE_USER, CONFLUENCE_TOKEN),
)
resp.raise_for_status()
data = resp.json()
results = []
for r in data.get("results", []):
results.append({
"id": r["id"],
"title": r.get("title", ""),
"space": r.get("space", {}).get("name", "") if "space" in r else "",
"url": f"{CONFLUENCE_URL}{r.get('_links', {}).get('webui', '')}",
})
return results
def _build_e2ee_options() -> rtc.E2EEOptions: def _build_e2ee_options() -> rtc.E2EEOptions:
"""Build E2EE options — let Rust FFI apply HKDF internally (KDF_HKDF=1). """Build E2EE options — let Rust FFI apply HKDF internally (KDF_HKDF=1).
@@ -902,6 +934,24 @@ class VoiceSession:
logger.warning("CONFLUENCE_UPDATE_FAIL: %s", exc) logger.warning("CONFLUENCE_UPDATE_FAIL: %s", exc)
return f"Failed to update page: {exc}" return f"Failed to update page: {exc}"
@function_tool
async def recent_confluence_pages() -> str:
"""List recently modified Confluence pages. Use this FIRST when the user
mentions Confluence, documents, or wiki pages — before searching.
Shows the last 5 recently edited pages so the user can pick one directly."""
logger.info("CONFLUENCE_RECENT")
try:
results = await _confluence_recent_pages(limit=5)
if not results:
return "No recent Confluence pages found."
lines = ["Recently modified pages:"]
for i, r in enumerate(results, 1):
lines.append(f"{i}. {r['title']} (ID: {r['id']}, Space: {r['space']})")
return "\n".join(lines)
except Exception as exc:
logger.warning("CONFLUENCE_RECENT_FAIL: %s", exc)
return f"Failed to fetch recent pages: {exc}"
@function_tool @function_tool
async def create_confluence_page(title: str, content: str, space_key: str = "AG") -> str: async def create_confluence_page(title: str, content: str, space_key: str = "AG") -> str:
"""Create a new Confluence page. Use when user asks to create a new document, """Create a new Confluence page. Use when user asks to create a new document,
@@ -1078,7 +1128,7 @@ class VoiceSession:
instructions += f"\n\nAktive Confluence-Seite: {_active_conf_id}. Du brauchst den Nutzer NICHT nach der page_id zu fragen — nutze automatisch diese ID fuer read_confluence_page und update_confluence_page." instructions += f"\n\nAktive Confluence-Seite: {_active_conf_id}. Du brauchst den Nutzer NICHT nach der page_id zu fragen — nutze automatisch diese ID fuer read_confluence_page und update_confluence_page."
agent = _NoiseFilterAgent( agent = _NoiseFilterAgent(
instructions=instructions, instructions=instructions,
tools=[search_web, set_user_timezone, search_confluence, read_confluence_page, update_confluence_page, create_confluence_page, think_deeper, look_at_screen], tools=[search_web, set_user_timezone, recent_confluence_pages, search_confluence, read_confluence_page, update_confluence_page, create_confluence_page, think_deeper, look_at_screen],
) )
io_opts = room_io.RoomOptions( io_opts = room_io.RoomOptions(
participant_identity=remote_identity, participant_identity=remote_identity,