feat: Add on-demand camera/screen vision via look_at_screen tool

Voice bot can now see the users camera or screen share when asked.
Captures a single frame, encodes as JPEG, sends to Sonnet vision
with full context (transcript + document). Triggered by phrases like
schau mal, siehst du das, can you see this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-24 06:36:52 +02:00
parent cfb26fb351
commit 326a874aa7
2 changed files with 101 additions and 2 deletions

View File

@@ -10,3 +10,4 @@ httpx>=0.27,<1.0
openai>=2.0,<3.0 openai>=2.0,<3.0
pymupdf>=1.24,<2.0 pymupdf>=1.24,<2.0
python-docx>=1.0,<2.0 python-docx>=1.0,<2.0
Pillow>=10.0,<12.0

102
voice.py
View File

@@ -51,7 +51,8 @@ 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 lesen und bearbeiten. Nutze read_confluence_page und update_confluence_page wenn der Nutzer Dokumente besprechen oder aendern moechte.""" - Du kannst Confluence-Seiten lesen und bearbeiten. Nutze read_confluence_page und update_confluence_page wenn der Nutzer Dokumente besprechen oder aendern moechte.
- 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."""
def _build_voice_prompt(model: str = "claude-sonnet", def _build_voice_prompt(model: str = "claude-sonnet",
@@ -386,6 +387,7 @@ class VoiceSession:
self._caller_user_id = caller_user_id # Matrix user ID for memory lookup self._caller_user_id = caller_user_id # Matrix user ID for memory lookup
self._document_context = document_context # PDF text from room for voice context self._document_context = document_context # PDF text from room for voice context
self._transcript: list[dict] = [] # {"role": "user"|"assistant", "text": "..."} self._transcript: list[dict] = [] # {"role": "user"|"assistant", "text": "..."}
self._video_track: rtc.Track | None = None # remote video track for on-demand vision
def on_encryption_key(self, sender, device_id, key, index): def on_encryption_key(self, sender, device_id, key, index):
"""Receive E2EE key from Element Call participant. """Receive E2EE key from Element Call participant.
@@ -576,6 +578,10 @@ class VoiceSession:
# subscription time. Calling set_key() BEFORE track subscription (at connect) # subscription time. Calling set_key() BEFORE track subscription (at connect)
# skips HKDF derivation → raw key stored → DEC_FAILED. # skips HKDF derivation → raw key stored → DEC_FAILED.
# Solution: set caller key HERE, after frame cryptor is initialized. # Solution: set caller key HERE, after frame cryptor is initialized.
# Store video track for on-demand vision (look_at_screen tool)
if int(t.kind) == 0: # video track
self._video_track = t
logger.info("Video track stored from %s for on-demand vision", p.identity)
if int(t.kind) == 1 and e2ee_opts is not None: # audio track only if int(t.kind) == 1 and e2ee_opts is not None: # audio track only
caller_id = p.identity caller_id = p.identity
logger.info("E2EE_DIAG: track_subscribed for %s, have %d caller keys", logger.info("E2EE_DIAG: track_subscribed for %s, have %d caller keys",
@@ -923,6 +929,98 @@ class VoiceSession:
logger.warning("THINK_DEEPER_FAIL: %s", exc) logger.warning("THINK_DEEPER_FAIL: %s", exc)
return f"Tiefere Analyse fehlgeschlagen: {exc}" return f"Tiefere Analyse fehlgeschlagen: {exc}"
# Vision tool — capture video frame and analyze with vision model
_video_track_ref = self # reference to VoiceSession for video track access
_lk_room_ref = self.lk_room
@function_tool
async def look_at_screen(question: str) -> str:
"""Schau dir an was der Nutzer auf dem Bildschirm oder per Kamera zeigt.
Nutze dieses Tool wenn:
- Der Nutzer sagt "schau mal", "siehst du das", "was siehst du", "look at this",
"can you see", "zeig dir was", "schau auf meinen Bildschirm", "kannst du das sehen"
- Der Nutzer seinen Bildschirm teilt und eine Frage dazu stellt
- Der Nutzer seine Kamera aktiviert hat und etwas zeigen moechte
Beschreibe was du sehen moechtest oder stelle eine Frage zum Bild."""
video_track = _video_track_ref._video_track
if not video_track:
return ("Kein Video verfuegbar. Der Nutzer muss seine Kamera oder "
"Bildschirmfreigabe aktivieren bevor ich etwas sehen kann.")
try:
# Capture single frame from video track
stream = rtc.VideoStream(video_track)
frame = None
async for f in stream:
frame = f
break
try:
await stream.aclose()
except Exception:
pass
if frame is None:
return "Konnte kein Bild aufnehmen — kein Frame verfuegbar."
# Handle both VideoFrameEvent (.frame) and direct VideoFrame
vf = getattr(frame, 'frame', frame)
# Convert to RGBA and encode as JPEG
rgba = vf.convert(rtc.VideoBufferType.RGBA)
from PIL import Image
import io
img = Image.frombytes("RGBA", (rgba.width, rgba.height), bytes(rgba.data))
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=85)
img_b64 = base64.b64encode(buf.getvalue()).decode()
logger.info("LOOK_AT_SCREEN: captured %dx%d frame (%d KB JPEG)",
rgba.width, rgba.height, len(buf.getvalue()) // 1024)
# Build context: transcript + document + question
context_parts = []
if _doc_context_ref:
context_parts.append(f"Dokument-Kontext:\n{_doc_context_ref[:8000]}")
recent = _transcript_ref[-10:] if _transcript_ref else []
if recent:
lines = [f"{'Nutzer' if e['role'] == 'user' else 'Assistent'}: {e['text']}"
for e in recent]
context_parts.append("Gespraechsverlauf:\n" + "\n".join(lines))
context_parts.append(f"Frage zum Bild: {question}")
text_prompt = "\n\n---\n\n".join(context_parts)
# Send to vision model via LiteLLM (OpenAI-compatible multimodal format)
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{LITELLM_URL}/chat/completions",
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
json={
"model": "claude-sonnet",
"messages": [
{"role": "system", "content": (
"Du analysierst Bilder von Bildschirm oder Kamera eines Nutzers. "
"Antworte praezise und hilfreich in der Sprache der Frage. "
"Beschreibe was du siehst und beantworte die Frage des Nutzers."
)},
{"role": "user", "content": [
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{img_b64}"}},
{"type": "text", "text": text_prompt},
]},
],
"max_tokens": 1500,
},
)
resp.raise_for_status()
data = resp.json()
answer = data["choices"][0]["message"]["content"]
logger.info("LOOK_AT_SCREEN_OK: %s", answer[:200])
return answer
except asyncio.TimeoutError:
return "Konnte kein Bild aufnehmen — Timeout. Ist die Kamera/Bildschirmfreigabe aktiv?"
except Exception as exc:
logger.warning("LOOK_AT_SCREEN_FAIL: %s", exc)
return f"Bildanalyse fehlgeschlagen: {exc}"
instructions = _build_voice_prompt(model=self.model, timezone=user_timezone) + memory_section instructions = _build_voice_prompt(model=self.model, timezone=user_timezone) + memory_section
if self._document_context: if self._document_context:
instructions += f"\n\nDokument-Kontext (im Raum hochgeladen):\n{self._document_context}" instructions += f"\n\nDokument-Kontext (im Raum hochgeladen):\n{self._document_context}"
@@ -930,7 +1028,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, read_confluence_page, update_confluence_page, think_deeper], tools=[search_web, set_user_timezone, read_confluence_page, update_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,