feat: the lens — the camera as a channel the dead look through
A new séance mode. The seeker opens their camera, presses "let it look", and the entity speaks about what is ACTUALLY in the room — the configured chat model (minicpm-v4.5:8b) is vision-capable, so this is real perception, not invented description. Same principle as every other channel here: real measurement first, interpretation second. Verified live end-to-end through the real WebSocket: given a synthetic room (pale doorway, red flame on dark boards), "Bessie L. Carter" reported the gray rectangle and red square on a dark surface with faint shadows, then misread it as her pen feeling heavy the night before Mr. Edgerton's birdseed arrived. Accuracy followed by wrongness, which is the whole effect. Privacy is the load-bearing design constraint, not a footnote: - "Camera open" and "the entity saw something" are deliberately separate states. Opening the lens transmits NOTHING; only an explicit press sends one still. There is no timer and no background capture path. - Frames are downscaled to 768px and JPEG-compressed client-side, then passed to the model and dropped. Never written to disk, never logged, never attached to an event row — only the resulting utterance is stored, exactly like any other thing a spirit says. - The prompt forbids describing faces or guessing anyone's identity, age or appearance; a person present is spoken of only as a presence. - A closed lens is covered by an opaque veil in the UI, so there is never ambiguity about whether the camera is live. Robustness: - CameraEye carries the same generation guard the EVP listener needed: closing during the permission prompt releases the late-arriving stream instead of letting the camera go live after teardown. - Failures are classified (denied / insecure / absent / busy / unknown) rather than always blaming the seeker for a refusal. - Scrying is the heaviest request this app makes of a CPU-only Ollama box, so it gets the tightest limiter of any channel (4/min/user, 8/min/IP). - Frames are size-capped BEFORE reaching the queue, and a vision failure emits an error frame instead of killing the socket — both covered by tests asserting the model was never called. 10 new frontend tests, 5 new backend tests. 385 frontend + backend suites pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -509,3 +509,139 @@ async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session
|
||||
).scalars().all()
|
||||
await asyncio.sleep(0.02)
|
||||
assert items == []
|
||||
|
||||
|
||||
# --- scry: the camera as a channel ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scry_speaks_about_what_the_lens_shows(sync_client, monkeypatch):
|
||||
"""The entity describes a real camera frame. The image must never be
|
||||
persisted — only the resulting utterance, like any other spirit speech."""
|
||||
seen_images = []
|
||||
|
||||
async def fake_scry(entity, image_b64, language="en", entropy=None):
|
||||
seen_images.append(image_b64)
|
||||
return "a pale column, and a red cube on the boards"
|
||||
|
||||
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
|
||||
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
|
||||
monkeypatch.setattr(
|
||||
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
|
||||
)
|
||||
_login(sync_client, "scryer")
|
||||
|
||||
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
||||
_read_until(ws, "session")
|
||||
ws.send_json({"type": "summon"})
|
||||
_read_until(ws, "entity")
|
||||
_read_until(ws, "utterance", kind="greeting")
|
||||
|
||||
ws.send_json({"type": "scry", "image": "ZmFrZS1qcGVn"})
|
||||
spoken = _read_until(ws, "utterance", kind="scry")
|
||||
assert spoken["text"] == "a pale column, and a red cube on the boards"
|
||||
|
||||
assert seen_images == ["ZmFrZS1qcGVn"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scry_without_a_presence_is_ignored(sync_client, monkeypatch):
|
||||
called = []
|
||||
|
||||
async def fake_scry(*a, **k):
|
||||
called.append(1)
|
||||
return "should not happen"
|
||||
|
||||
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
|
||||
_login(sync_client, "scryer-nobody")
|
||||
|
||||
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
||||
_read_until(ws, "session")
|
||||
# Nothing summoned: there is nobody to look through the lens.
|
||||
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
|
||||
ws.send_json({"type": "ping"})
|
||||
assert _read_until(ws, "pong") == {"type": "pong"}
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scry_rejects_an_oversized_frame_before_the_model(sync_client, monkeypatch):
|
||||
called = []
|
||||
|
||||
async def fake_scry(*a, **k):
|
||||
called.append(1)
|
||||
return "nope"
|
||||
|
||||
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
|
||||
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
|
||||
monkeypatch.setattr(
|
||||
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
|
||||
)
|
||||
_login(sync_client, "scryer-huge")
|
||||
|
||||
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
||||
_read_until(ws, "session")
|
||||
ws.send_json({"type": "summon"})
|
||||
_read_until(ws, "entity")
|
||||
|
||||
ws.send_json({"type": "scry", "image": "A" * (app.ws.MAX_SCRY_B64_CHARS + 1)})
|
||||
err = _read_until(ws, "error")
|
||||
assert err["code"] == "scry_too_large"
|
||||
|
||||
# The oversized payload must never have reached the vision model.
|
||||
assert called == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scry_survives_a_vision_failure(sync_client, monkeypatch):
|
||||
"""A broken vision call must leave the séance usable, not kill the socket."""
|
||||
|
||||
async def exploding_scry(*a, **k):
|
||||
raise RuntimeError("the model fell over")
|
||||
|
||||
monkeypatch.setattr(app.ws.spirit_service, "scry", exploding_scry, raising=False)
|
||||
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
|
||||
monkeypatch.setattr(
|
||||
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
|
||||
)
|
||||
_login(sync_client, "scryer-broken")
|
||||
|
||||
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
||||
_read_until(ws, "session")
|
||||
ws.send_json({"type": "summon"})
|
||||
_read_until(ws, "entity")
|
||||
|
||||
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
|
||||
err = _read_until(ws, "error")
|
||||
assert err["code"] == "scry_failed"
|
||||
|
||||
# Still alive afterwards.
|
||||
ws.send_json({"type": "ping"})
|
||||
assert _read_until(ws, "pong") == {"type": "pong"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scry_is_rate_limited(sync_client, monkeypatch):
|
||||
async def fake_scry(*a, **k):
|
||||
return "it looks"
|
||||
|
||||
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
|
||||
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
|
||||
monkeypatch.setattr(
|
||||
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
|
||||
)
|
||||
monkeypatch.setattr(app.ws, "scry_limiter", RateLimiter(max_requests=1, window_seconds=60))
|
||||
_login(sync_client, "scryer-limited")
|
||||
|
||||
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
||||
_read_until(ws, "session")
|
||||
ws.send_json({"type": "summon"})
|
||||
_read_until(ws, "entity")
|
||||
|
||||
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
|
||||
_read_until(ws, "utterance", kind="scry")
|
||||
|
||||
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
|
||||
err = _read_until(ws, "error")
|
||||
assert err["code"] == "rate_limited"
|
||||
|
||||
Reference in New Issue
Block a user