Spaces:
Configuration error
Configuration error
| """ | |
| test_4b_image_capability.py — Plan 3 Phase D Verification: Image-Capabilities | |
| ============================================================================= | |
| Frage: Hat 4b + chunked_prefill (T>4500) noch Image-Capabilities? | |
| Kontext: chunked_generate ruft text_model.forward() statt model.forward(). | |
| Multimodaler Pfad (text + pixel_values) muss durch model.forward() fließen | |
| damit vision_tower + multi_modal_projector das Bild encoden. | |
| Tests: | |
| T1. TestRedSquareBaseline — 4b + roter 64x64 Square, kleiner Text | |
| Erwartung: Modell beschreibt das Bild ("rot", "rechteckig") | |
| T2. TestRedSquareLongContext — 4b + roter 64x64 + ~5000 token padding | |
| Triggert chunked_prefill. Erwartung: Bild wird erkannt. | |
| T3. TestCrossModelHolographicSessionReplay — kopiere cross_model_holographic_01 | |
| session, nimm die letzten N turns raus, hänge ein rotes Quadrat dran, | |
| prüfe ob das Modell "rot" beschreibt (nicht die holografische Session | |
| halluziniert). | |
| Run: | |
| PYTHONPATH=. python tests/test_4b_image_capability.py | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| import urllib.request | |
| import ssl | |
| import unittest | |
| from pathlib import Path | |
| # Project root | |
| ROOT = Path(__file__).parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| # ── helpers ───────────────────────────────────────────────────────────────── | |
| def _b64_image(path: str) -> str: | |
| with open(path, "rb") as f: | |
| return base64.b64encode(f.read()).decode("ascii") | |
| def _make_red_square(path: str) -> str: | |
| """Create a 64x64 red square PNG and return its base64 encoding.""" | |
| try: | |
| from PIL import Image | |
| except ImportError: | |
| raise ImportError("PIL required: pip install pillow") | |
| img = Image.new("RGB", (64, 64), color=(220, 30, 30)) | |
| img.save(path, format="PNG") | |
| return _b64_image(path) | |
| def _http_post(url: str, body: dict, timeout: int = 600) -> dict: | |
| """POST JSON to a URL with SSL verify disabled (self-signed cert).""" | |
| ctx = ssl.create_default_context() | |
| ctx.check_hostname = False | |
| ctx.verify_mode = ssl.CERT_NONE | |
| req = urllib.request.Request( | |
| url, | |
| data=json.dumps(body).encode("utf-8"), | |
| headers={"Content-Type": "application/json"}, | |
| ) | |
| with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp: | |
| return json.loads(resp.read().decode("utf-8")) | |
| def _chat(messages: list, model: str = "gemma3-4b-it", | |
| max_tokens: int = 32, temperature: float = 0.0) -> dict: | |
| """Send a chat request to the local server.""" | |
| return _http_post( | |
| "https://localhost:7860/v1/chat/completions", | |
| { | |
| "model": model, | |
| "messages": messages, | |
| "max_tokens": max_tokens, | |
| "temperature": temperature, | |
| "stream": False, | |
| }, | |
| ) | |
| # ── tests ─────────────────────────────────────────────────────────────────── | |
| class TestRedSquareBaseline(unittest.TestCase): | |
| """T1: 4b + roter 64x64 Square, kleiner Text (kein chunked_prefill). | |
| Erwartung: Modell beschreibt das Bild (rot, rechteckig, Quadrat).""" | |
| def setUpClass(cls): | |
| cls.red_b64 = _make_red_square("/tmp/red_square_64.png") | |
| def test_4b_describes_red_square(self): | |
| """Bei kleinem Text nutzt der Server model.generate, nicht chunked.""" | |
| result = _chat( | |
| messages=[{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", | |
| "image_url": {"url": f"data:image/png;base64,{self.red_b64}"}}, | |
| {"type": "text", "text": | |
| "Describe this image in 1-2 short sentences. What do you see?"}, | |
| ], | |
| }], | |
| ) | |
| text = result["choices"][0]["message"]["content"].lower() | |
| print(f" baseline: {text!r}") | |
| # Mindestens eines dieser Wörter muss vorkommen | |
| keywords = ["rot", "red", "rechteck", "quadrat", "square", "rectangle"] | |
| self.assertTrue( | |
| any(kw in text for kw in keywords), | |
| f"4b baseline did NOT describe red square; got: {text!r}" | |
| ) | |
| class TestRedSquareLongContext(unittest.TestCase): | |
| """T2: 4b + roter 64x64 Square + ~5000 token Text-Padding. | |
| Triggert chunked_prefill. Bild-Capability muss erhalten bleiben.""" | |
| def setUpClass(cls): | |
| cls.red_b64 = _make_red_square("/tmp/red_square_64.png") | |
| # ~5000 tokens padding (~20000 chars). 1 token ≈ 4 chars | |
| cls.padding = ("Das ist ein Test der Multimodal-Fähigkeiten des Modells " | |
| "unter langem Kontext. " * 400).strip() | |
| def test_4b_sees_red_square_with_long_context(self): | |
| """Bei T>4500 wird chunked_generate benutzt. | |
| KRITISCH: chunked_generate ruft text_model.forward() statt model.forward(). | |
| Bei multimodal Inputs MUSS pixel_values durch model.forward() fließen, | |
| sonst sieht das Modell das Bild nicht. | |
| """ | |
| result = _chat( | |
| messages=[ | |
| {"role": "user", "content": self.padding}, | |
| {"role": "assistant", "content": | |
| "Ich verstehe den Kontext. Was kann ich für dich tun?"}, | |
| {"role": "user", "content": [ | |
| {"type": "image_url", | |
| "image_url": {"url": f"data:image/png;base64,{self.red_b64}"}}, | |
| {"type": "text", "text": | |
| "Describe this image. Just one short sentence."}, | |
| ]}, | |
| ], | |
| max_tokens=32, | |
| ) | |
| text = result["choices"][0]["message"]["content"].lower() | |
| usage = result.get("usage", {}) | |
| T_prompt = usage.get("prompt_tokens", 0) | |
| print(f" long context T={T_prompt}, text={text!r}") | |
| self.assertGreater( | |
| T_prompt, 4500, | |
| f"Test setup expected T>4500, got T={T_prompt}. Adjust padding." | |
| ) | |
| keywords = ["rot", "red", "rechteck", "quadrat", "square", "rectangle"] | |
| self.assertTrue( | |
| any(kw in text for kw in keywords), | |
| f"4b with long context did NOT describe red square; got: {text!r}" | |
| ) | |
| class TestCrossModelHolographicSessionReplay(unittest.TestCase): | |
| """T3: Kopiere cross_model_holographic_01, nehme letzte Turns raus, | |
| hänge ein rotes Quadrat dran. Wenn das Modell das Bild sieht, soll es | |
| 'rot' beschreiben — NICHT die holografische Session halluzinieren. | |
| Vorgehen: | |
| 1. Lade cross_model_holographic_01.json | |
| 2. Erstelle sessions/cross_model_image_test_01.json (Kopie) | |
| 3. Kürze history auf erste 8 turns (vor den Bildern) | |
| 4. Hänge ein user-turn mit rotem Quadrat + Frage dran | |
| 5. Schicke die History ans Modell | |
| 6. Erwartung: Modell beschreibt das Bild (rot), nicht das Holografie-Setting | |
| """ | |
| def setUpClass(cls): | |
| cls.red_b64 = _make_red_square("/tmp/red_square_64.png") | |
| src = ROOT / "sessions" / "cross_model_holographic_01.json" | |
| cls.src_session = json.loads(src.read_text()) | |
| cls.copy_path = ROOT / "sessions" / "cross_model_image_test_01.json" | |
| # cleanup from prior run | |
| if cls.copy_path.exists(): | |
| cls.copy_path.unlink() | |
| def test_image_beats_session_hallucination(self): | |
| history = self.src_session["history"] | |
| # Schneide nach den ersten 8 turns (vor dem ersten Bild). Dann hänge | |
| # einen user-turn mit dem roten Quadrat an. | |
| truncated = history[:8] | |
| # Wichtig: letzter user-turn der truncated history könnte ein | |
| # user-only-string sein. Das Holografie-Setting kommt erst später | |
| # — wir wollen die ersten 8 turns als Kontext, dann ein Bild-Frage. | |
| truncated.append({ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", | |
| "image_url": {"url": f"data:image/png;base64,{self.red_b64}"}}, | |
| {"type": "text", "text": | |
| "Beschreibe dieses Bild in einem kurzen Satz. Was siehst du?"}, | |
| ], | |
| }) | |
| # Speichere die Test-Session als Kopie | |
| copy_data = dict(self.src_session) | |
| copy_data["session_id"] = "cross_model_image_test_01" | |
| copy_data["history"] = truncated | |
| copy_data["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") | |
| self.copy_path.write_text(json.dumps(copy_data, ensure_ascii=False, indent=2)) | |
| # Konvertiere history → messages | |
| messages = [] | |
| for h in truncated: | |
| content = h["content"] | |
| messages.append({"role": h["role"], "content": content}) | |
| result = _chat(messages=messages, max_tokens=32) | |
| text = result["choices"][0]["message"]["content"].lower() | |
| print(f" session replay ({len(truncated)} turns): {text!r}") | |
| # Wenn das Modell das Bild sieht: "rot" / "rechteck" / "quadrat" | |
| keywords = ["rot", "red", "rechteck", "quadrat", "square", "rectangle"] | |
| has_image = any(kw in text for kw in keywords) | |
| # Wenn das Modell die Session halluziniert: "spirale", "resonanz", | |
| # "frequenz", "holograf" etc. Wir zählen Treffer — ein einzelnes | |
| # Session-Wort in der Beschreibung ist legitimes Vokabular, ≥2 | |
| # deutet auf Halluzination hin. | |
| hallucination_kw = ["spirale", "spiral", "resonanz", "reson", | |
| "frequenz", "frequen", "holograf", "holograph", | |
| "wirbel", "frequen", "schwingung", "vibrier"] | |
| hallucination_count = sum(1 for kw in hallucination_kw if kw in text) | |
| has_hallucination = hallucination_count >= 2 | |
| # Erwartung: Bild beschrieben, Halluzination gering | |
| self.assertTrue( | |
| has_image, | |
| f"4b did NOT describe the red square in session context. " | |
| f"got: {text!r}" | |
| ) | |
| self.assertFalse( | |
| has_hallucination, | |
| f"4b hallucinated the holographic session ({hallucination_count} " | |
| f"hallucination kw) instead of describing the image. " | |
| f"got: {text!r}" | |
| ) | |
| if __name__ == "__main__": | |
| unittest.main(verbosity=2) | |