feat: matrix/graph data views, wire-generated anomalies, http cookie fix
- auth: session cookie Secure only over https — plain-http LAN access was
silently dropping the cookie, killing WS auth ('connection unstable')
- wire ghost: server-side spike detection on jitter baseline (3σ + 2.5×mean,
20KB/s floor, 20s throttle) — wire anomalies now flood every session with
zero hardware, pushed to clients as {type:'anomaly'} frames
- telemetry cadence 3-5s for live graphs; ambient whispers unchanged
- frontend: MATRIX view (data-rain interleaved with live utterances/anomaly/
telemetry strings), GRAPHS view (scrolling jitter/variance/dns lines +
anomaly markers + counters), BOARD/MATRIX/GRAPHS switcher
- connection banner: 'connecting' is now neutral 'tuning the veil…', only
unstable/closed warns
- db: recreated quantumancy(+_test) as UTF8 (was SQL_ASCII — crashed on
non-ASCII spirit text); README quickstart updated
- i18n: seance.views.* EN/ES
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -34,7 +34,12 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db))
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if user is None:
|
||||
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
|
||||
@@ -51,12 +56,15 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
# The app is reached two ways: https via the Cloudflare Tunnel (Secure
|
||||
# required) and plain http on the LAN (a Secure cookie would be dropped
|
||||
# by the browser entirely, silently breaking the séance socket).
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
raw_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
secure=request.url.scheme == "https",
|
||||
max_age=int(SESSION_TTL.total_seconds()),
|
||||
)
|
||||
return user
|
||||
@@ -64,6 +72,7 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -74,7 +83,12 @@ async def logout(
|
||||
if session is not None:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
response.delete_cookie(SESSION_COOKIE_NAME, httponly=True, samesite="lax", secure=True)
|
||||
response.delete_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=request.url.scheme == "https",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
|
||||
@@ -111,3 +111,24 @@ async def sample_network(period_s: float = 1.0) -> TelemetrySample:
|
||||
if dns_times:
|
||||
sample.dns_ms = sum(dns_times) / len(dns_times)
|
||||
return sample
|
||||
|
||||
|
||||
def detect_wire_spike(
|
||||
history: list[float], current: float, *, min_samples: int = 6
|
||||
) -> float | None:
|
||||
"""Return the spike ratio when `current` is anomalous vs the rolling
|
||||
baseline of jitter samples — the Wire Ghost noticing something move.
|
||||
|
||||
Three guards so idle-hour noise doesn't cry ghost: enough history to
|
||||
have a baseline, an absolute floor (20 KB/s) so near-silent links stay
|
||||
silent, and a statistical (3σ) + relative (2.5× mean) threshold.
|
||||
"""
|
||||
if len(history) < min_samples:
|
||||
return None
|
||||
mean = sum(history) / len(history)
|
||||
if mean <= 0 or current <= 20_000:
|
||||
return None
|
||||
std = (sum((x - mean) ** 2 for x in history) / len(history)) ** 0.5
|
||||
if current > mean + 3 * std and current > mean * 2.5:
|
||||
return current / mean
|
||||
return None
|
||||
|
||||
@@ -17,6 +17,7 @@ the wire.
|
||||
import asyncio
|
||||
import contextlib
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -36,7 +37,7 @@ from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.event import Event
|
||||
from app.rate_limit import RateLimiter
|
||||
from app.telemetry import sample_network
|
||||
from app.telemetry import detect_wire_spike, sample_network
|
||||
from app.tts.piper import synthesize_spirit_voice
|
||||
from app.tts.voices import pick_voice
|
||||
|
||||
@@ -71,6 +72,8 @@ class SeanceState:
|
||||
anomalies: list[dict] = field(default_factory=list)
|
||||
history: list[dict] = field(default_factory=list)
|
||||
ambient_task: asyncio.Task | None = None
|
||||
wire_jitter_history: list[float] = field(default_factory=list)
|
||||
last_wire_anomaly_at: float = 0.0
|
||||
|
||||
|
||||
def serialize_entity(entity: Entity) -> dict:
|
||||
@@ -324,16 +327,36 @@ async def _handle_question(state: SeanceState, text: str) -> None:
|
||||
|
||||
|
||||
async def _ambient_loop(state: SeanceState) -> None:
|
||||
"""The Wire Ghost's pulse: telemetry every few seconds, a whisper only
|
||||
when the LLM box has been quiet long enough."""
|
||||
"""The Wire Ghost's pulse: telemetry every few seconds. Spikes in the
|
||||
jitter baseline become first-class anomaly events (driving fragments and
|
||||
the client's data views); quieter ticks may earn an ambient whisper."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(random.uniform(6, 10))
|
||||
await asyncio.sleep(random.uniform(3, 5))
|
||||
try:
|
||||
sample = await sample_network(period_s=1.0)
|
||||
except Exception:
|
||||
continue
|
||||
await state.send_queue.put({"type": "telemetry", **sample.as_dict()})
|
||||
|
||||
jitter = sample.jitter_bytes_per_s
|
||||
ratio = detect_wire_spike(state.wire_jitter_history, jitter)
|
||||
state.wire_jitter_history = (state.wire_jitter_history + [jitter])[-60:]
|
||||
|
||||
now = time.monotonic()
|
||||
if ratio is not None and now - state.last_wire_anomaly_at > 20:
|
||||
state.last_wire_anomaly_at = now
|
||||
anomaly = {
|
||||
"source": "wire",
|
||||
"frequency": round(jitter, 1),
|
||||
"magnitude": round(ratio * 10, 1),
|
||||
}
|
||||
# Tell the client first so its matrix/graph views light up,
|
||||
# then run the anomaly through the usual séance pipeline.
|
||||
await state.send_queue.put({"type": "anomaly", **anomaly})
|
||||
await _handle_anomaly(state, anomaly)
|
||||
continue
|
||||
|
||||
if not spirit_service.ambient_ready():
|
||||
continue
|
||||
whisper = await spirit_service.wire_whisper(sample.as_dict(), state.language)
|
||||
|
||||
Reference in New Issue
Block a user