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:
@@ -10,3 +10,4 @@ httpx>=0.27,<1.0
|
||||
openai>=2.0,<3.0
|
||||
pymupdf>=1.24,<2.0
|
||||
python-docx>=1.0,<2.0
|
||||
Pillow>=10.0,<12.0
|
||||
|
||||
102
voice.py
102
voice.py
@@ -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.
|
||||
- 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.
|
||||
- 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",
|
||||
@@ -386,6 +387,7 @@ class VoiceSession:
|
||||
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._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):
|
||||
"""Receive E2EE key from Element Call participant.
|
||||
@@ -576,6 +578,10 @@ class VoiceSession:
|
||||
# subscription time. Calling set_key() BEFORE track subscription (at connect)
|
||||
# skips HKDF derivation → raw key stored → DEC_FAILED.
|
||||
# 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
|
||||
caller_id = p.identity
|
||||
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)
|
||||
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
|
||||
if 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."
|
||||
agent = _NoiseFilterAgent(
|
||||
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(
|
||||
participant_identity=remote_identity,
|
||||
|
||||
Reference in New Issue
Block a user