diff --git a/requirements.txt b/requirements.txt index f46830b..d0ebf8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/voice.py b/voice.py index f9585c1..295ae64 100644 --- a/voice.py +++ b/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,