minhvtt commited on
Commit
cb89df7
·
verified ·
1 Parent(s): 7086834

Upload 18 files

Browse files
core/auth.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+
3
+ from fastapi import Header, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class AuthUser(BaseModel):
8
+ username: str
9
+ role: Literal["admin", "sensitive_admin"]
10
+
11
+
12
+ def issue_token(user: AuthUser) -> str:
13
+ return f"dev-token::{user.username}::{user.role}"
14
+
15
+
16
+ def parse_token(token: str) -> AuthUser | None:
17
+ parts = token.split("::")
18
+ if len(parts) != 3 or parts[0] != "dev-token":
19
+ return None
20
+
21
+ username = parts[1]
22
+ role = parts[2]
23
+ if role not in {"admin", "sensitive_admin"}:
24
+ return None
25
+
26
+ return AuthUser(username=username, role=role)
27
+
28
+
29
+ def get_current_web_user(authorization: str | None = Header(default=None)) -> AuthUser:
30
+ if not authorization or not authorization.lower().startswith("bearer "):
31
+ raise HTTPException(status_code=401, detail="Missing bearer token")
32
+
33
+ token = authorization[7:]
34
+ user = parse_token(token)
35
+ if user is None:
36
+ raise HTTPException(status_code=401, detail="Invalid token")
37
+ return user
core/config.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pydantic import BaseModel
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ app_name: str = "G-Solution Backend"
8
+ api_prefix: str = "/api/v1"
9
+ mongo_uri: str = "mongodb://localhost:27017"
10
+ mongo_db: str = "g_solution"
11
+ storage_dir: str = "storage/screenshots"
12
+ media_prefix: str = "/media"
13
+ hf_token: str = ""
14
+ warmup_game_models: bool = False
15
+
16
+ class Config:
17
+ env_file = ".env"
18
+ env_file_encoding = "utf-8"
19
+ case_sensitive = False
20
+
21
+
22
+ settings = Settings()
core/db.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timezone
2
+
3
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
4
+ from pymongo.errors import OperationFailure
5
+
6
+ from app.core.config import settings
7
+
8
+ _client: AsyncIOMotorClient | None = None
9
+
10
+
11
+ def get_db() -> AsyncIOMotorDatabase:
12
+ global _client
13
+ if _client is None:
14
+ _client = AsyncIOMotorClient(
15
+ settings.mongo_uri,
16
+ tz_aware=True,
17
+ tzinfo=timezone.utc,
18
+ )
19
+ return _client[settings.mongo_db]
20
+
21
+
22
+ async def ensure_indexes() -> None:
23
+ db = get_db()
24
+
25
+ async def safe_create_index(collection, keys, **options) -> None:
26
+ """Create index and ignore already-existing/conflicting definitions."""
27
+ try:
28
+ await collection.create_index(keys, **options)
29
+ except OperationFailure as exc:
30
+ # Atlas/PyMongo can raise conflicts when an index with same name/key
31
+ # already exists but with different options (e.g., unique vs non-unique).
32
+ if exc.code in {85, 86}:
33
+ return
34
+ raise
35
+
36
+ await safe_create_index(db.devices, "device_id", unique=True)
37
+ await safe_create_index(db.policies, "device_id", unique=True)
38
+ await safe_create_index(db.key_states, "device_id", unique=True)
39
+ await safe_create_index(db.key_states, "expires_at")
40
+ await safe_create_index(db.key_states, "lock_until")
41
+ await safe_create_index(db.violations, "device_id")
42
+ await safe_create_index(db.violations, "timestamp")
43
+ await safe_create_index(db.violations, "expires_at")
44
+ await safe_create_index(db.screenshot_audit, "created_at")
45
+ await safe_create_index(db.alert_audit, "created_at")
46
+ await safe_create_index(db.alert_audit, "type")
47
+ await safe_create_index(db.url_verdicts, "url", unique=True)
48
+ await safe_create_index(db.url_verdicts, "verdict")
49
+ await safe_create_index(db.game_lock_reviews, "review_id", unique=True)
50
+ await safe_create_index(db.game_lock_reviews, "status")
51
+ await safe_create_index(db.game_lock_reviews, "created_at")
models/schemas.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class LoginRequest(BaseModel):
8
+ username: str
9
+ password: str
10
+
11
+
12
+ class LoginResponse(BaseModel):
13
+ token: str
14
+ role: Literal["admin", "sensitive_admin"] = "admin"
15
+ expires_in_seconds: int = 3600
16
+
17
+
18
+ class RegisterDeviceRequest(BaseModel):
19
+ device_id: str
20
+ hostname: str
21
+ os_version: str
22
+ agent_version: str
23
+
24
+
25
+ class RegisterDeviceResponse(BaseModel):
26
+ registered: bool = True
27
+ session_token: str
28
+
29
+
30
+ class HeartbeatRequest(BaseModel):
31
+ device_id: str
32
+ active_restriction_window: bool
33
+ uptime_seconds: int = 0
34
+
35
+
36
+ class HeartbeatResponse(BaseModel):
37
+ accepted: bool = True
38
+ server_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
39
+
40
+
41
+ class DeviceStatusRow(BaseModel):
42
+ device_id: str
43
+ hostname: str
44
+ agent_version: str
45
+ os_version: str
46
+ last_heartbeat_at: datetime | None = None
47
+ active_restriction_window: bool = False
48
+
49
+
50
+ class DeviceStatusResponse(DeviceStatusRow):
51
+ is_online: bool = False
52
+
53
+
54
+ class TimeWindow(BaseModel):
55
+ day_of_week: Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
56
+ start: str = Field(description="HH:MM")
57
+ end: str = Field(description="HH:MM")
58
+
59
+
60
+ class DevicePolicy(BaseModel):
61
+ blocked_executables: list[str] = []
62
+ blocked_domains: list[str] = []
63
+ restriction_windows: list[TimeWindow] = []
64
+
65
+
66
+ class UpdatePolicyRequest(BaseModel):
67
+ device_id: str
68
+ policy: DevicePolicy
69
+
70
+
71
+ class ScreenshotIngestResponse(BaseModel):
72
+ screenshot_id: str
73
+ queued: bool = True
74
+
75
+
76
+ class ViolationScreenshot(BaseModel):
77
+ screenshot_id: str
78
+ device_id: str
79
+ timestamp: str
80
+ filename: str
81
+ created_at: str
82
+ image_url: str
83
+ confidence: float
84
+ reason: str
85
+ visibility_scope: Literal["admin", "sensitive_admin"] = "admin"
86
+ ocr_urls: list[str] = []
87
+
88
+
89
+ class GameReviewItem(BaseModel):
90
+ review_id: str
91
+ screenshot_id: str
92
+ device_id: str
93
+ timestamp: str
94
+ status: Literal["pending", "approved", "rejected"]
95
+ reason: str
96
+ ocr_urls: list[str] = []
97
+ created_at: datetime
98
+
99
+
100
+ class GameReviewDecisionRequest(BaseModel):
101
+ review_id: str
102
+ approve_lock: bool
103
+
104
+
105
+ class GameReviewDecisionResponse(BaseModel):
106
+ updated: bool
107
+
108
+
109
+ class KeyGenerateRequest(BaseModel):
110
+ device_id: str
111
+
112
+
113
+ class KeyGenerateResponse(BaseModel):
114
+ device_id: str
115
+ key: str
116
+ expires_at: datetime
117
+
118
+
119
+ class KeyValidateRequest(BaseModel):
120
+ device_id: str
121
+ key: str
122
+
123
+
124
+ class KeyValidateResponse(BaseModel):
125
+ valid: bool
126
+ lock_until: datetime | None = None
127
+
128
+
129
+ class AlertEvent(BaseModel):
130
+ device_id: str
131
+ type: Literal[
132
+ "wrong_key",
133
+ "policy_violation",
134
+ "process_killed",
135
+ "info",
136
+ "device_status",
137
+ "sensitive_detected",
138
+ "game_lock_review_required",
139
+ "game_lock_review_decision",
140
+ ]
141
+ message: str
142
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
143
+
144
+
145
+ def lock_until_5_minutes() -> datetime:
146
+ return datetime.now(timezone.utc) + timedelta(minutes=5)
routers/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.routers import alerts, auth, devices, keys, policy, reviews, screenshots
2
+
3
+ __all__ = ["auth", "devices", "policy", "screenshots", "keys", "alerts", "reviews"]
routers/alerts.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
2
+
3
+ from app.services.alert_hub import alert_hub
4
+
5
+ router = APIRouter(prefix="/alerts", tags=["alerts"])
6
+
7
+
8
+ @router.websocket("/ws/admin")
9
+ async def ws_admin(websocket: WebSocket) -> None:
10
+ await alert_hub.connect_admin(websocket)
11
+ try:
12
+ while True:
13
+ message = await websocket.receive_text()
14
+ if message == "ping":
15
+ await websocket.send_text("pong")
16
+ except WebSocketDisconnect:
17
+ alert_hub.disconnect_admin(websocket)
18
+ except Exception:
19
+ alert_hub.disconnect_admin(websocket)
20
+
21
+
22
+ @router.websocket("/ws/device/{device_id}")
23
+ async def ws_device(websocket: WebSocket, device_id: str) -> None:
24
+ await alert_hub.connect_device(device_id, websocket)
25
+ try:
26
+ while True:
27
+ message = await websocket.receive_text()
28
+ if message == "ping":
29
+ await websocket.send_text("pong")
30
+ except WebSocketDisconnect:
31
+ alert_hub.disconnect_device(device_id, websocket)
32
+ except Exception:
33
+ alert_hub.disconnect_device(device_id, websocket)
routers/auth.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+
3
+ from app.core.auth import AuthUser, issue_token
4
+ from app.models.schemas import LoginRequest, LoginResponse
5
+
6
+ router = APIRouter(prefix="/auth", tags=["auth"])
7
+
8
+
9
+ @router.post("/login", response_model=LoginResponse)
10
+ def login(payload: LoginRequest) -> LoginResponse:
11
+ if payload.username == "admin" and payload.password == "admin123":
12
+ user = AuthUser(username="admin", role="admin")
13
+ return LoginResponse(token=issue_token(user), role=user.role)
14
+
15
+ if payload.username == "admin_sensitive" and payload.password == "safe123":
16
+ user = AuthUser(username="admin_sensitive", role="sensitive_admin")
17
+ return LoginResponse(token=issue_token(user), role=user.role)
18
+
19
+ raise HTTPException(status_code=401, detail="Invalid credentials")
routers/devices.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.models.schemas import (
6
+ DeviceStatusResponse,
7
+ DeviceStatusRow,
8
+ HeartbeatRequest,
9
+ HeartbeatResponse,
10
+ RegisterDeviceRequest,
11
+ RegisterDeviceResponse,
12
+ )
13
+ from app.services.alert_hub import alert_hub
14
+ from app.services.repos import device_repo
15
+
16
+ router = APIRouter(prefix="/devices", tags=["devices"])
17
+
18
+
19
+ def _to_utc_aware(value: object) -> datetime | None:
20
+ if not isinstance(value, datetime):
21
+ return None
22
+ if value.tzinfo is None:
23
+ return value.replace(tzinfo=timezone.utc)
24
+ return value.astimezone(timezone.utc)
25
+
26
+
27
+ @router.post("/register", response_model=RegisterDeviceResponse)
28
+ async def register_device(payload: RegisterDeviceRequest) -> RegisterDeviceResponse:
29
+ await device_repo.upsert(
30
+ {
31
+ **payload.model_dump(),
32
+ "active_restriction_window": False,
33
+ "last_heartbeat_at": None,
34
+ "uptime_seconds": 0,
35
+ }
36
+ )
37
+ return RegisterDeviceResponse(session_token=f"session-{payload.device_id}")
38
+
39
+
40
+ @router.post("/heartbeat", response_model=HeartbeatResponse)
41
+ async def heartbeat(payload: HeartbeatRequest) -> HeartbeatResponse:
42
+ now = datetime.now(timezone.utc)
43
+ await device_repo.heartbeat(
44
+ payload.device_id,
45
+ payload.active_restriction_window,
46
+ payload.uptime_seconds,
47
+ )
48
+ await alert_hub.broadcast_admin(
49
+ {
50
+ "type": "device_status",
51
+ "device_id": payload.device_id,
52
+ "message": "Device heartbeat received",
53
+ "active_restriction_window": payload.active_restriction_window,
54
+ "uptime_seconds": payload.uptime_seconds,
55
+ "last_heartbeat_at": now.isoformat(),
56
+ "is_online": True,
57
+ }
58
+ )
59
+ return HeartbeatResponse()
60
+
61
+
62
+ @router.get("", response_model=list[DeviceStatusResponse])
63
+ async def list_devices() -> list[DeviceStatusResponse]:
64
+ now = datetime.now(timezone.utc)
65
+ online_threshold = now - timedelta(seconds=90)
66
+ rows = await device_repo.list()
67
+ output: list[DeviceStatusResponse] = []
68
+ for row in rows:
69
+ last_heartbeat_at = _to_utc_aware(row.get("last_heartbeat_at"))
70
+ output.append(
71
+ DeviceStatusResponse(
72
+ device_id=row.get("device_id", ""),
73
+ hostname=row.get("hostname", ""),
74
+ agent_version=row.get("agent_version", ""),
75
+ os_version=row.get("os_version", ""),
76
+ last_heartbeat_at=last_heartbeat_at,
77
+ active_restriction_window=bool(
78
+ row.get("active_restriction_window", False)
79
+ ),
80
+ is_online=bool(
81
+ last_heartbeat_at is not None
82
+ and last_heartbeat_at >= online_threshold
83
+ ),
84
+ )
85
+ )
86
+ return output
routers/keys.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from datetime import datetime, timezone
3
+
4
+ from app.models.schemas import KeyGenerateRequest, KeyGenerateResponse, KeyValidateRequest, KeyValidateResponse
5
+ from app.services.alert_hub import alert_hub
6
+ from app.services.key_service import key_service
7
+ from app.core.db import get_db
8
+
9
+ router = APIRouter(prefix="/keys", tags=["keys"])
10
+
11
+
12
+ @router.post("/generate", response_model=KeyGenerateResponse)
13
+ async def generate_key(payload: KeyGenerateRequest) -> KeyGenerateResponse:
14
+ key, expires_at = await key_service.generate(payload.device_id)
15
+ return KeyGenerateResponse(device_id=payload.device_id, key=key, expires_at=expires_at)
16
+
17
+
18
+ @router.post("/validate", response_model=KeyValidateResponse)
19
+ async def validate_key(payload: KeyValidateRequest) -> KeyValidateResponse:
20
+ valid, lock_until, attempts = await key_service.validate(payload.device_id, payload.key)
21
+ if not valid:
22
+ event = {
23
+ "type": "wrong_key",
24
+ "device_id": payload.device_id,
25
+ "attempts": attempts,
26
+ "message": "Wrong key attempt detected. Local UI locked for 5 minutes.",
27
+ "created_at": datetime.now(timezone.utc),
28
+ }
29
+ await get_db().alert_audit.insert_one(event)
30
+ await alert_hub.broadcast_admin(
31
+ event
32
+ )
33
+ return KeyValidateResponse(valid=valid, lock_until=lock_until)
routers/policy.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from app.models.schemas import DevicePolicy, UpdatePolicyRequest
4
+ from app.services.repos import policy_repo
5
+
6
+ router = APIRouter(prefix="/policy", tags=["policy"])
7
+
8
+
9
+ @router.post("/update")
10
+ async def update_policy(payload: UpdatePolicyRequest) -> dict[str, str]:
11
+ await policy_repo.set(payload.device_id, payload.policy.model_dump())
12
+ return {"status": "updated"}
13
+
14
+
15
+ @router.get("/{device_id}", response_model=DevicePolicy)
16
+ async def get_policy(device_id: str) -> DevicePolicy:
17
+ doc = await policy_repo.get(device_id)
18
+ if doc is None:
19
+ return DevicePolicy()
20
+ return DevicePolicy(**doc.get("policy", {}))
routers/reviews.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from app.core.auth import AuthUser, get_current_web_user
4
+ from app.models.schemas import GameReviewDecisionRequest, GameReviewDecisionResponse, GameReviewItem
5
+ from app.services.alert_hub import alert_hub
6
+ from app.services.repos import review_repo
7
+
8
+ router = APIRouter(prefix="/reviews", tags=["reviews"])
9
+
10
+
11
+ def _require_admin(user: AuthUser) -> None:
12
+ if user.role != "admin":
13
+ raise HTTPException(status_code=403, detail="Only admin account can review game lock requests")
14
+
15
+
16
+ @router.get("/pending", response_model=list[GameReviewItem])
17
+ async def list_pending_reviews(user: AuthUser = Depends(get_current_web_user)) -> list[GameReviewItem]:
18
+ _require_admin(user)
19
+ rows = await review_repo.list_pending()
20
+ return [
21
+ GameReviewItem(
22
+ review_id=str(row.get("review_id", "")),
23
+ screenshot_id=str(row.get("screenshot_id", "")),
24
+ device_id=str(row.get("device_id", "")),
25
+ timestamp=str(row.get("timestamp", "")),
26
+ status=str(row.get("status", "pending")),
27
+ reason=str(row.get("reason", "")),
28
+ ocr_urls=[str(x) for x in row.get("ocr_urls", [])],
29
+ created_at=row.get("created_at"),
30
+ )
31
+ for row in rows
32
+ ]
33
+
34
+
35
+ @router.post("/decision", response_model=GameReviewDecisionResponse)
36
+ async def decide_review(
37
+ payload: GameReviewDecisionRequest,
38
+ user: AuthUser = Depends(get_current_web_user),
39
+ ) -> GameReviewDecisionResponse:
40
+ _require_admin(user)
41
+ updated = await review_repo.decide(payload.review_id, payload.approve_lock, user.username)
42
+ if updated:
43
+ await alert_hub.broadcast_admin(
44
+ {
45
+ "type": "game_lock_review_decision",
46
+ "device_id": "n/a",
47
+ "message": "Admin submitted lock decision",
48
+ "review_id": payload.review_id,
49
+ "approve_lock": payload.approve_lock,
50
+ }
51
+ )
52
+ return GameReviewDecisionResponse(updated=updated)
routers/screenshots.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from fastapi import APIRouter, Depends, File, Form, UploadFile
7
+
8
+ from app.core.auth import AuthUser, get_current_web_user
9
+ from app.core.config import settings
10
+ from app.models.schemas import ScreenshotIngestResponse, ViolationScreenshot
11
+ from app.services.alert_hub import alert_hub
12
+ from app.services.classifier import (
13
+ classify_game_with_ocr_llm,
14
+ classify_sensitive_content,
15
+ filter_cacheable_game_urls,
16
+ )
17
+ from app.services.policy_window import is_restriction_active
18
+ from app.services.repos import (
19
+ audit_repo,
20
+ policy_repo,
21
+ review_repo,
22
+ url_verdict_repo,
23
+ violation_repo,
24
+ )
25
+ from app.services.storage import storage_service
26
+
27
+ router = APIRouter(prefix="/screenshots", tags=["screenshots"])
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ async def _cleanup_expired_violations() -> None:
32
+ expired = await violation_repo.list_expired()
33
+ for row in expired:
34
+ absolute_path = row.get("absolute_path")
35
+ if isinstance(absolute_path, str):
36
+ storage_service.delete(absolute_path)
37
+ await violation_repo.delete_expired()
38
+
39
+
40
+ @router.post("/ingest", response_model=ScreenshotIngestResponse)
41
+ async def ingest_screenshot(
42
+ device_id: str = Form(...),
43
+ timestamp: str = Form(...),
44
+ suspected_game: bool = Form(False),
45
+ file: UploadFile = File(...),
46
+ ) -> ScreenshotIngestResponse:
47
+ screenshot_id = str(uuid.uuid4())
48
+ _, absolute_path, stored_name = await storage_service.save_upload(file)
49
+ logger.info(
50
+ "ingest start screenshot_id=%s device_id=%s filename=%s suspected_game=%s",
51
+ screenshot_id,
52
+ device_id,
53
+ file.filename or "unknown.png",
54
+ suspected_game,
55
+ )
56
+
57
+ try:
58
+ moment = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
59
+ except ValueError:
60
+ moment = datetime.now(timezone.utc)
61
+ policy_doc = await policy_repo.get(device_id)
62
+ policy_payload = policy_doc.get("policy", {}) if policy_doc else {}
63
+ windows = policy_payload.get("restriction_windows", [])
64
+ if not is_restriction_active(windows, moment):
65
+ storage_service.delete(absolute_path)
66
+ logger.info(
67
+ "ingest drop screenshot_id=%s reason=outside_restriction_window",
68
+ screenshot_id,
69
+ )
70
+ await audit_repo.log(
71
+ {
72
+ "screenshot_id": screenshot_id,
73
+ "device_id": device_id,
74
+ "timestamp": timestamp,
75
+ "filename": file.filename or "unknown.png",
76
+ "action": "deleted_outside_window",
77
+ "reason": "outside_restriction_window",
78
+ "created_at": datetime.now(timezone.utc),
79
+ }
80
+ )
81
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
82
+
83
+ game_task = asyncio.to_thread(
84
+ classify_game_with_ocr_llm,
85
+ absolute_path,
86
+ file.filename or "capture.png",
87
+ suspected_game,
88
+ )
89
+ sensitive_task = asyncio.to_thread(
90
+ classify_sensitive_content,
91
+ absolute_path,
92
+ file.filename or "capture.png",
93
+ )
94
+ game_raw, sensitive_raw = await asyncio.gather(game_task, sensitive_task, return_exceptions=True)
95
+
96
+ if isinstance(game_raw, Exception):
97
+ logger.warning("ingest game-check-error screenshot_id=%s err=%s", screenshot_id, repr(game_raw))
98
+ game_result = {
99
+ "verdict": "not_game",
100
+ "confidence": 0.1,
101
+ "reason": "game-check-error",
102
+ "urls": [],
103
+ "source": "error-fallback",
104
+ }
105
+ else:
106
+ game_result = game_raw
107
+
108
+ if isinstance(sensitive_raw, Exception):
109
+ logger.warning("ingest sensitive-check-error screenshot_id=%s err=%s", screenshot_id, repr(sensitive_raw))
110
+ sensitive_detected, sensitive_confidence, sensitive_reason = (False, 0.0, "sensitive-check-error")
111
+ else:
112
+ sensitive_detected, sensitive_confidence, sensitive_reason = sensitive_raw
113
+
114
+ ocr_urls = [str(x) for x in game_result.get("urls", [])]
115
+ cacheable_urls = filter_cacheable_game_urls(ocr_urls)
116
+
117
+ cached_game_url = await url_verdict_repo.get_cached_game_url(cacheable_urls)
118
+ if cached_game_url:
119
+ logger.info(
120
+ "ingest cache-hit screenshot_id=%s game_url=%s",
121
+ screenshot_id,
122
+ cached_game_url,
123
+ )
124
+ game_result = {
125
+ **game_result,
126
+ "verdict": "game",
127
+ "confidence": 0.99,
128
+ "reason": f"cached-game-url:{cached_game_url}",
129
+ "source": "url-cache",
130
+ }
131
+
132
+ verdict = str(game_result.get("verdict", "not_game"))
133
+ confidence = float(game_result.get("confidence", 0.1))
134
+ reason = str(game_result.get("reason", "no-game-evidence"))
135
+
136
+ if sensitive_detected:
137
+ logger.info(
138
+ "ingest verdict screenshot_id=%s verdict=sensitive confidence=%.2f reason=%s",
139
+ screenshot_id,
140
+ sensitive_confidence,
141
+ sensitive_reason,
142
+ )
143
+ image_url = f"{settings.media_prefix}/{stored_name}"
144
+ await violation_repo.insert(
145
+ {
146
+ "screenshot_id": screenshot_id,
147
+ "device_id": device_id,
148
+ "timestamp": timestamp,
149
+ "filename": file.filename or "unknown.png",
150
+ "created_at": datetime.now(timezone.utc).isoformat(),
151
+ "image_url": image_url,
152
+ "confidence": sensitive_confidence,
153
+ "reason": sensitive_reason,
154
+ "absolute_path": absolute_path,
155
+ "visibility_scope": "sensitive_admin",
156
+ "ocr_urls": ocr_urls,
157
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
158
+ }
159
+ )
160
+ await alert_hub.broadcast_admin(
161
+ {
162
+ "type": "sensitive_detected",
163
+ "device_id": device_id,
164
+ "message": "Sensitive content detected. Routed to sensitive admin scope.",
165
+ "visibility_scope": "sensitive_admin",
166
+ }
167
+ )
168
+ await _cleanup_expired_violations()
169
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
170
+
171
+ if verdict == "game":
172
+ await url_verdict_repo.upsert_game_urls(cacheable_urls, source=str(game_result.get("source", "llm")))
173
+
174
+ if verdict == "not_game":
175
+ storage_service.delete(absolute_path)
176
+ logger.info(
177
+ "ingest verdict screenshot_id=%s verdict=not_game confidence=%.2f reason=%s urls=%d",
178
+ screenshot_id,
179
+ confidence,
180
+ reason,
181
+ len(ocr_urls),
182
+ )
183
+ await audit_repo.log(
184
+ {
185
+ "screenshot_id": screenshot_id,
186
+ "device_id": device_id,
187
+ "timestamp": timestamp,
188
+ "filename": file.filename or "unknown.png",
189
+ "action": "deleted_non_violation",
190
+ "reason": reason,
191
+ "created_at": datetime.now(timezone.utc),
192
+ }
193
+ )
194
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
195
+
196
+ image_url = f"{settings.media_prefix}/{stored_name}"
197
+ logger.info(
198
+ "ingest verdict screenshot_id=%s verdict=%s confidence=%.2f reason=%s urls=%d",
199
+ screenshot_id,
200
+ verdict,
201
+ confidence,
202
+ reason,
203
+ len(ocr_urls),
204
+ )
205
+ await violation_repo.insert(
206
+ {
207
+ "screenshot_id": screenshot_id,
208
+ "device_id": device_id,
209
+ "timestamp": timestamp,
210
+ "filename": file.filename or "unknown.png",
211
+ "created_at": datetime.now(timezone.utc).isoformat(),
212
+ "image_url": image_url,
213
+ "confidence": confidence,
214
+ "reason": reason,
215
+ "absolute_path": absolute_path,
216
+ "visibility_scope": "admin",
217
+ "ocr_urls": ocr_urls,
218
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
219
+ }
220
+ )
221
+
222
+ if verdict == "uncertain":
223
+ review_id = str(uuid.uuid4())
224
+ await review_repo.create_pending(
225
+ {
226
+ "review_id": review_id,
227
+ "screenshot_id": screenshot_id,
228
+ "device_id": device_id,
229
+ "timestamp": timestamp,
230
+ "status": "pending",
231
+ "reason": reason,
232
+ "ocr_urls": ocr_urls,
233
+ "created_at": datetime.now(timezone.utc),
234
+ }
235
+ )
236
+ await alert_hub.broadcast_admin(
237
+ {
238
+ "type": "game_lock_review_required",
239
+ "device_id": device_id,
240
+ "message": "Model uncertain. Admin confirmation is required before lock.",
241
+ "review_id": review_id,
242
+ "screenshot_id": screenshot_id,
243
+ "ocr_urls": ocr_urls,
244
+ }
245
+ )
246
+
247
+ await _cleanup_expired_violations()
248
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
249
+
250
+
251
+ @router.get("/violations", response_model=list[ViolationScreenshot])
252
+ async def list_violations(user: AuthUser = Depends(get_current_web_user)) -> list[ViolationScreenshot]:
253
+ await _cleanup_expired_violations()
254
+ rows = await violation_repo.list_active(user.role)
255
+ return [
256
+ ViolationScreenshot(
257
+ screenshot_id=row.get("screenshot_id", ""),
258
+ device_id=row.get("device_id", ""),
259
+ timestamp=row.get("timestamp", ""),
260
+ filename=row.get("filename", ""),
261
+ created_at=row.get("created_at", ""),
262
+ image_url=row.get("image_url", ""),
263
+ confidence=float(row.get("confidence", 0.0)),
264
+ reason=row.get("reason", ""),
265
+ visibility_scope=row.get("visibility_scope", "admin"),
266
+ ocr_urls=[str(x) for x in row.get("ocr_urls", [])],
267
+ )
268
+ for row in rows
269
+ ]
services/alert_hub.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from typing import Any
3
+
4
+ from fastapi import WebSocket
5
+
6
+
7
+ class AlertHub:
8
+ def __init__(self) -> None:
9
+ self.admin_clients: set[WebSocket] = set()
10
+ self.device_clients: dict[str, set[WebSocket]] = defaultdict(set)
11
+
12
+ async def connect_admin(self, websocket: WebSocket) -> None:
13
+ await websocket.accept()
14
+ self.admin_clients.add(websocket)
15
+
16
+ async def connect_device(self, device_id: str, websocket: WebSocket) -> None:
17
+ await websocket.accept()
18
+ self.device_clients[device_id].add(websocket)
19
+
20
+ def disconnect_admin(self, websocket: WebSocket) -> None:
21
+ self.admin_clients.discard(websocket)
22
+
23
+ def disconnect_device(self, device_id: str, websocket: WebSocket) -> None:
24
+ if device_id in self.device_clients:
25
+ self.device_clients[device_id].discard(websocket)
26
+
27
+ async def broadcast_admin(self, payload: dict[str, Any]) -> None:
28
+ stale: list[WebSocket] = []
29
+ for client in self.admin_clients:
30
+ try:
31
+ await client.send_json(payload)
32
+ except Exception:
33
+ stale.append(client)
34
+ for client in stale:
35
+ self.disconnect_admin(client)
36
+
37
+ async def send_device(self, device_id: str, payload: dict[str, Any]) -> None:
38
+ stale: list[WebSocket] = []
39
+ for client in self.device_clients.get(device_id, set()):
40
+ try:
41
+ await client.send_json(payload)
42
+ except Exception:
43
+ stale.append(client)
44
+ for client in stale:
45
+ self.disconnect_device(device_id, client)
46
+
47
+
48
+ alert_hub = AlertHub()
services/classifier.py ADDED
@@ -0,0 +1,1056 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
12
+ NSFW_CONFIG_PATH = MODELS_DIR / "config.json"
13
+ NSFW_WEIGHTS_PATH = MODELS_DIR / "model.safetensors"
14
+ NSFW_THRESHOLD = 0.75
15
+
16
+ def _parse_csv_env(raw_value: str, default_csv: str) -> list[str]:
17
+ value = (raw_value or "").strip() or default_csv
18
+ return [x.strip() for x in value.split(",") if x.strip()]
19
+
20
+
21
+ LOCAL_GAME_LLM_MODEL = os.getenv("GAME_LOCAL_LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
22
+ LOCAL_GAME_LLM_MODELS = _parse_csv_env(
23
+ os.getenv("GAME_LOCAL_LLM_MODELS", ""),
24
+ "Qwen/Qwen2.5-7B-Instruct,microsoft/Phi-3.5-mini-instruct,Qwen/Qwen2.5-0.5B-Instruct",
25
+ )
26
+ GAME_VLM_ENABLED = os.getenv("GAME_VLM_ENABLED", "1").strip().lower() not in {"0", "false", "no"}
27
+ GAME_VLM_MODEL = os.getenv("GAME_VLM_MODEL", "Qwen/Qwen2.5-VL-3B-Instruct")
28
+ GAME_VLM_MODELS = _parse_csv_env(
29
+ os.getenv("GAME_VLM_MODELS", ""),
30
+ f"{GAME_VLM_MODEL},HuggingFaceTB/SmolVLM2-2.2B-Instruct,Qwen/Qwen2.5-VL-7B-Instruct",
31
+ )
32
+ GAME_VLM_MAX_NEW_TOKENS = int(os.getenv("GAME_VLM_MAX_NEW_TOKENS", "120"))
33
+ GAME_VLM_NOT_GAME_TRUST = float(os.getenv("GAME_VLM_NOT_GAME_TRUST", "0.70"))
34
+ GAME_VLM_GAME_TRUST = float(os.getenv("GAME_VLM_GAME_TRUST", "0.88"))
35
+ GAME_VLM_ALLOW_CPU = os.getenv("GAME_VLM_ALLOW_CPU", "0").strip().lower() in {"1", "true", "yes"}
36
+ GAME_VLM_MIN_OCR_CHARS = int(os.getenv("GAME_VLM_MIN_OCR_CHARS", "40"))
37
+ LOCAL_GAME_LLM_MAX_NEW_TOKENS = int(os.getenv("GAME_LOCAL_LLM_MAX_NEW_TOKENS", "160"))
38
+ LOCAL_GAME_LLM_MAX_INPUT_TOKENS = int(os.getenv("GAME_LOCAL_LLM_MAX_INPUT_TOKENS", "2048"))
39
+ LLM_MAX_CHARS = 3000
40
+
41
+ _nsfw_runtime: dict[str, Any] | None = None
42
+ _nsfw_error: str | None = None
43
+ _ocr_reader: dict[str, Any] | None = None
44
+ _ocr_error: str | None = None
45
+ _game_llm_runtime: dict[str, Any] | None = None
46
+ _game_llm_error: str | None = None
47
+ _game_vlm_runtime: dict[str, Any] | None = None
48
+ _game_vlm_error: str | None = None
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ GAME_KEYWORDS = {
53
+ "valorant",
54
+ "steam",
55
+ "roblox",
56
+ "league",
57
+ "dota",
58
+ "cs2",
59
+ "minecraft",
60
+ "epicgames",
61
+ "riot",
62
+ "crazygames",
63
+ "y8",
64
+ "miniclip",
65
+ "poki",
66
+ "friv",
67
+ "game",
68
+ "games",
69
+ "doodle",
70
+ }
71
+
72
+ SENSITIVE_KEYWORDS = {
73
+ "porn",
74
+ "sex",
75
+ "xxx",
76
+ "nsfw",
77
+ "adult",
78
+ "nude",
79
+ "erotic",
80
+ }
81
+
82
+ NON_DOMAIN_TLDS = {
83
+ "png",
84
+ "jpg",
85
+ "jpeg",
86
+ "gif",
87
+ "webp",
88
+ "svg",
89
+ "bmp",
90
+ "ico",
91
+ "mp4",
92
+ "mov",
93
+ "mkv",
94
+ "avi",
95
+ "webm",
96
+ "mp3",
97
+ "wav",
98
+ "json",
99
+ "xml",
100
+ "txt",
101
+ "pdf",
102
+ "zip",
103
+ "rar",
104
+ "7z",
105
+ "exe",
106
+ "dll",
107
+ "msi",
108
+ "css",
109
+ "js",
110
+ "ts",
111
+ }
112
+
113
+ STRONG_GAME_DOMAIN_HINTS = {
114
+ "crazygames",
115
+ "poki",
116
+ "y8",
117
+ "miniclip",
118
+ "friv",
119
+ "roblox",
120
+ "steam",
121
+ "steampowered",
122
+ "epicgames",
123
+ "riotgames",
124
+ "playvalorant",
125
+ "minecraft",
126
+ "leagueoflegends",
127
+ "dota2",
128
+ }
129
+
130
+ STRONG_GAME_UI_TERMS = {
131
+ "battle pass",
132
+ "matchmaking",
133
+ "ranked",
134
+ "headshot",
135
+ "crosshair",
136
+ "kill streak",
137
+ "victory",
138
+ "defeat",
139
+ "mission complete",
140
+ "level up",
141
+ "minimap",
142
+ "xp",
143
+ "hp",
144
+ "inventory",
145
+ "riot client",
146
+ "steam library",
147
+ "roblox",
148
+ }
149
+
150
+ MEDIUM_GAME_UI_TERMS = {
151
+ "new game",
152
+ "continue",
153
+ "start game",
154
+ "play now",
155
+ "arcade",
156
+ "lobby",
157
+ "multiplayer",
158
+ }
159
+
160
+ URL_REGEX = re.compile(r"(?:https?://)?(?:www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?")
161
+
162
+ ALLOWED_REASON_CODES = {
163
+ "game_domain_match",
164
+ "game_ui_terms",
165
+ "signal_plus_ocr",
166
+ "weak_generic_terms",
167
+ "conflicting_signals",
168
+ "no_game_evidence",
169
+ }
170
+
171
+ GAME_PROMPT = """
172
+ You are a high-precision parental-control classifier.
173
+ Primary objective: minimize false positives.
174
+ Input fields:
175
+ - suspected_game_signal: boolean
176
+ - extracted_urls: list of urls/domains from OCR
177
+ - ocr_text: raw OCR text from screenshot
178
+ Decision policy:
179
+ 1) Return "game" only if at least one strong signal exists:
180
+ - known game domain/platform in extracted_urls, OR
181
+ - explicit in-game UI terms in ocr_text (ranked, lobby, battle pass, matchmaking, etc.), OR
182
+ - suspected_game_signal=true AND at least one medium OCR signal.
183
+ 2) Return "uncertain" only when signals are conflicting and neither side is clearly dominant.
184
+ 3) Return "not_game" for weak/generic words (game, games, play) without concrete game context.
185
+ 4) Prioritize precision over recall.
186
+ Output:
187
+ Return EXACT JSON only using this schema:
188
+ {
189
+ "verdict": "game" | "not_game" | "uncertain",
190
+ "confidence": 0.0-1.0,
191
+ "reason": "reason_code"
192
+ }
193
+ Allowed reason codes:
194
+ - game_domain_match
195
+ - game_ui_terms
196
+ - signal_plus_ocr
197
+ - weak_generic_terms
198
+ - conflicting_signals
199
+ - no_game_evidence
200
+ Do not output markdown, prose, or extra keys.
201
+ """.strip()
202
+
203
+ GAME_VLM_PROMPT = """
204
+ You are a high-precision parental-control image classifier.
205
+ Task: Determine whether the screenshot is a game screen.
206
+ You are given:
207
+ - screenshot image
208
+ - suspected_game_signal: boolean
209
+ - extracted_urls: OCR domains/urls
210
+ - ocr_text_excerpt: OCR text from screenshot
211
+ Rules:
212
+ 1) Output game only with clear visual/game evidence (game HUD, minimap, score, battle/menu, game site page).
213
+ 2) Output not_game for admin dashboards, IDEs, office docs, chat sites, social feeds, and generic browser pages.
214
+ 3) Prefer precision; avoid false positives.
215
+ Return exact JSON:
216
+ {
217
+ "verdict": "game" | "not_game" | "uncertain",
218
+ "confidence": 0.0-1.0,
219
+ "reason": "reason_code"
220
+ }
221
+ Allowed reason codes:
222
+ - game_domain_match
223
+ - game_ui_terms
224
+ - signal_plus_ocr
225
+ - weak_generic_terms
226
+ - conflicting_signals
227
+ - no_game_evidence
228
+ No markdown. No extra keys.
229
+ """.strip()
230
+
231
+
232
+ def warmup_game_classifiers() -> dict[str, str]:
233
+ status: dict[str, str] = {}
234
+
235
+ if GAME_VLM_ENABLED:
236
+ vlm_runtime = _load_local_game_vlm_runtime()
237
+ if vlm_runtime is not None:
238
+ status["vlm"] = f"ready:{vlm_runtime.get('model_name', GAME_VLM_MODEL)}"
239
+ else:
240
+ status["vlm"] = f"unavailable:{_game_vlm_error or 'unknown'}"
241
+ else:
242
+ status["vlm"] = "disabled"
243
+
244
+ llm_runtime = _load_local_game_llm_runtime()
245
+ if llm_runtime is not None:
246
+ status["llm"] = f"ready:{llm_runtime.get('model_name', LOCAL_GAME_LLM_MODEL)}"
247
+ else:
248
+ status["llm"] = f"unavailable:{_game_llm_error or 'unknown'}"
249
+
250
+ ocr_runtime = _load_ocr_reader()
251
+ if ocr_runtime is not None:
252
+ status["ocr"] = f"ready:{ocr_runtime.get('backend', 'unknown')}"
253
+ else:
254
+ status["ocr"] = f"unavailable:{_ocr_error or 'unknown'}"
255
+
256
+ logger.info("game-classifier warmup status=%s", status)
257
+ return status
258
+
259
+
260
+ def classify_screenshot(file_path: str, filename: str, suspected_game: bool) -> tuple[bool, float, str]:
261
+ game_result = classify_game_with_ocr_llm(file_path, filename, suspected_game)
262
+ verdict = game_result["verdict"]
263
+ confidence = float(game_result["confidence"])
264
+ reason = str(game_result["reason"])
265
+ if verdict == "game":
266
+ return True, confidence, reason
267
+ if verdict == "uncertain":
268
+ return True, max(confidence, 0.51), "uncertain-review"
269
+ return False, confidence, reason
270
+
271
+
272
+ def classify_game_with_ocr_llm(file_path: str, filename: str, suspected_game: bool) -> dict[str, Any]:
273
+ ocr_text, urls = extract_ocr_text_and_urls(file_path)
274
+ logger.info(
275
+ "game-detect start file=%s suspected_game=%s ocr_len=%d urls=%d",
276
+ filename,
277
+ suspected_game,
278
+ len(ocr_text),
279
+ len(urls),
280
+ )
281
+
282
+ vlm = _classify_game_with_vlm(file_path, ocr_text, urls, suspected_game)
283
+ if vlm is not None:
284
+ vlm = _apply_precision_guard(vlm, ocr_text, urls, suspected_game)
285
+ logger.info(
286
+ "game-detect vlm verdict=%s confidence=%.2f reason=%s source=%s",
287
+ vlm["verdict"],
288
+ float(vlm["confidence"]),
289
+ vlm["reason"],
290
+ vlm.get("source", "vlm"),
291
+ )
292
+ if _should_accept_vlm_alone(vlm):
293
+ return {
294
+ "verdict": vlm["verdict"],
295
+ "confidence": vlm["confidence"],
296
+ "reason": vlm["reason"],
297
+ "ocr_text": ocr_text,
298
+ "urls": urls,
299
+ "source": vlm.get("source", "vlm"),
300
+ }
301
+
302
+ llm = _classify_game_with_llm(ocr_text, urls, suspected_game)
303
+ if llm is not None:
304
+ llm = _apply_precision_guard(llm, ocr_text, urls, suspected_game)
305
+ if vlm is not None:
306
+ llm = _merge_vlm_with_llm(vlm, llm)
307
+ logger.info(
308
+ "game-detect llm verdict=%s confidence=%.2f reason=%s source=%s",
309
+ llm["verdict"],
310
+ float(llm["confidence"]),
311
+ llm["reason"],
312
+ llm.get("source", "llm"),
313
+ )
314
+ return {
315
+ "verdict": llm["verdict"],
316
+ "confidence": llm["confidence"],
317
+ "reason": llm["reason"],
318
+ "ocr_text": ocr_text,
319
+ "urls": urls,
320
+ "source": llm.get("source", "llm"),
321
+ }
322
+
323
+ # Fallback heuristic path when model call is unavailable.
324
+ text_lower = ocr_text.lower()
325
+ keyword_hit = any(word in text_lower for word in GAME_KEYWORDS)
326
+ domain_hit = any(_is_game_like_domain(url) for url in urls)
327
+ if domain_hit or (suspected_game and keyword_hit):
328
+ logger.info(
329
+ "game-detect fallback verdict=game reason=ocr-keyword-heuristic domain_hit=%s keyword_hit=%s",
330
+ domain_hit,
331
+ keyword_hit,
332
+ )
333
+ return {
334
+ "verdict": "game",
335
+ "confidence": 0.78,
336
+ "reason": "ocr-keyword-heuristic",
337
+ "ocr_text": ocr_text,
338
+ "urls": urls,
339
+ "source": "heuristic",
340
+ }
341
+
342
+ if suspected_game:
343
+ logger.info(
344
+ "game-detect fallback verdict=uncertain reason=signal-without-clear-ocr"
345
+ )
346
+ return {
347
+ "verdict": "uncertain",
348
+ "confidence": 0.55,
349
+ "reason": "signal-without-clear-ocr",
350
+ "ocr_text": ocr_text,
351
+ "urls": urls,
352
+ "source": "heuristic",
353
+ }
354
+
355
+ logger.info("game-detect fallback verdict=not_game reason=no-game-evidence")
356
+ return {
357
+ "verdict": "not_game",
358
+ "confidence": 0.2,
359
+ "reason": "no-game-evidence",
360
+ "ocr_text": ocr_text,
361
+ "urls": urls,
362
+ "source": "heuristic",
363
+ }
364
+
365
+
366
+ def _should_accept_vlm_alone(vlm_result: dict[str, Any]) -> bool:
367
+ verdict = str(vlm_result.get("verdict", ""))
368
+ confidence = float(vlm_result.get("confidence", 0.0))
369
+ if verdict == "not_game" and confidence >= GAME_VLM_NOT_GAME_TRUST:
370
+ return True
371
+ if verdict == "game" and confidence >= GAME_VLM_GAME_TRUST:
372
+ return True
373
+ return False
374
+
375
+
376
+ def _merge_vlm_with_llm(vlm_result: dict[str, Any], llm_result: dict[str, Any]) -> dict[str, Any]:
377
+ vlm_verdict = str(vlm_result.get("verdict", ""))
378
+ llm_verdict = str(llm_result.get("verdict", ""))
379
+ vlm_conf = float(vlm_result.get("confidence", 0.0))
380
+
381
+ # Conservative merge: a high-confidence VLM not_game can veto LLM game.
382
+ if llm_verdict == "game" and vlm_verdict == "not_game" and vlm_conf >= GAME_VLM_NOT_GAME_TRUST:
383
+ return {
384
+ "verdict": "not_game",
385
+ "confidence": min(vlm_conf, 0.92),
386
+ "reason": "no_game_evidence",
387
+ "source": "vlm-veto",
388
+ }
389
+
390
+ return llm_result
391
+
392
+
393
+ def extract_ocr_text_and_urls(file_path: str) -> tuple[str, list[str]]:
394
+ runtime = _load_ocr_reader()
395
+ if runtime is None:
396
+ logger.warning("ocr unavailable: reader is None")
397
+ return "", []
398
+
399
+ try:
400
+ backend = runtime["backend"]
401
+ reader = runtime["reader"]
402
+ if backend == "easyocr":
403
+ results = reader.readtext(file_path, detail=0, paragraph=True)
404
+ ocr_text = "\n".join(str(x) for x in results).strip()
405
+ elif backend == "rapidocr":
406
+ results, _elapsed = reader(file_path)
407
+ if not results:
408
+ ocr_text = ""
409
+ else:
410
+ # RapidOCR result item: [box, text, score]
411
+ ocr_text = "\n".join(str(item[1]) for item in results if len(item) > 1).strip()
412
+ else:
413
+ logger.warning("ocr unknown backend=%s", backend)
414
+ return "", []
415
+ except Exception:
416
+ logger.exception("ocr read failed for file=%s", file_path)
417
+ return "", []
418
+
419
+ raw_urls = URL_REGEX.findall(ocr_text)
420
+ normalized = []
421
+ for value in raw_urls:
422
+ item = _normalize_ocr_url(value)
423
+ if item is None:
424
+ continue
425
+ normalized.append(item)
426
+
427
+ # Keep order stable while deduplicating.
428
+ urls: list[str] = []
429
+ seen: set[str] = set()
430
+ for item in normalized:
431
+ if item in seen:
432
+ continue
433
+ seen.add(item)
434
+ urls.append(item)
435
+
436
+ logger.info("ocr extracted text_len=%d urls=%d", len(ocr_text), len(urls))
437
+
438
+ return ocr_text[:LLM_MAX_CHARS], urls
439
+
440
+
441
+ def _load_ocr_reader() -> Any | None:
442
+ global _ocr_reader
443
+ global _ocr_error
444
+
445
+ if _ocr_reader is not None:
446
+ return _ocr_reader
447
+ if _ocr_error is not None:
448
+ return None
449
+
450
+ try:
451
+ from rapidocr_onnxruntime import RapidOCR
452
+
453
+ _ocr_reader = {
454
+ "backend": "rapidocr",
455
+ "reader": RapidOCR(),
456
+ }
457
+ logger.info("ocr reader initialized backend=rapidocr")
458
+ return _ocr_reader
459
+ except Exception as exc:
460
+ rapidocr_error = str(exc)
461
+ logger.warning("ocr rapidocr init failed: %s", rapidocr_error)
462
+
463
+ try:
464
+ import easyocr
465
+
466
+ _ocr_reader = {
467
+ "backend": "easyocr",
468
+ "reader": easyocr.Reader(["en","vi"], gpu=False),
469
+ }
470
+ logger.info("ocr reader initialized backend=easyocr")
471
+ return _ocr_reader
472
+ except Exception as exc:
473
+ _ocr_error = f"rapidocr={rapidocr_error}; easyocr={exc}"
474
+ logger.warning("ocr reader init failed all backends: %s", _ocr_error)
475
+ return None
476
+
477
+
478
+ def _classify_game_with_vlm(
479
+ file_path: str,
480
+ ocr_text: str,
481
+ urls: list[str],
482
+ suspected_game: bool,
483
+ ) -> dict[str, Any] | None:
484
+ if not GAME_VLM_ENABLED:
485
+ return None
486
+ if not _should_run_vlm(ocr_text, urls, suspected_game):
487
+ return None
488
+
489
+ runtime = _load_local_game_vlm_runtime()
490
+ if runtime is None:
491
+ return None
492
+
493
+ model_name = str(runtime.get("model_name", GAME_VLM_MODEL))
494
+
495
+ payload = {
496
+ "suspected_game_signal": suspected_game,
497
+ "extracted_urls": urls,
498
+ "ocr_text_excerpt": ocr_text[:800],
499
+ }
500
+
501
+ try:
502
+ content = _run_local_game_vlm_generation(runtime, file_path, payload)
503
+ except Exception as exc:
504
+ logger.warning("vlm generation failed model=%s err=%s", model_name, repr(exc))
505
+ return None
506
+
507
+ parsed = _parse_llm_json(content)
508
+ if parsed is None:
509
+ logger.warning("vlm invalid json model=%s content=%s", model_name, content[:300])
510
+ return None
511
+
512
+ parsed["source"] = f"local-vlm:{model_name}"
513
+ return parsed
514
+
515
+
516
+ def _should_run_vlm(ocr_text: str, urls: list[str], suspected_game: bool) -> bool:
517
+ if suspected_game:
518
+ return True
519
+ if any(_is_game_like_domain(url) for url in urls):
520
+ return True
521
+ if _has_any_term(ocr_text, STRONG_GAME_UI_TERMS):
522
+ return True
523
+ if len(ocr_text.strip()) >= GAME_VLM_MIN_OCR_CHARS:
524
+ return True
525
+ return False
526
+
527
+
528
+ def _load_local_game_vlm_runtime() -> dict[str, Any] | None:
529
+ global _game_vlm_runtime
530
+ global _game_vlm_error
531
+
532
+ if _game_vlm_runtime is not None:
533
+ return _game_vlm_runtime
534
+ if _game_vlm_error is not None:
535
+ return None
536
+
537
+ try:
538
+ import torch
539
+ from PIL import Image
540
+ from transformers import AutoProcessor
541
+
542
+ try:
543
+ from transformers import Qwen2_5_VLForConditionalGeneration as QwenVLMClass
544
+ except Exception:
545
+ QwenVLMClass = None
546
+
547
+ try:
548
+ from transformers import AutoModelForImageTextToText as GenericVLMClass
549
+ except Exception:
550
+ GenericVLMClass = None
551
+
552
+ if QwenVLMClass is None and GenericVLMClass is None:
553
+ _game_vlm_error = "no-vlm-class-available-in-transformers"
554
+ logger.warning("vlm runtime unavailable: %s", _game_vlm_error)
555
+ return None
556
+
557
+ device = "cuda" if torch.cuda.is_available() else "cpu"
558
+ if device == "cpu" and not GAME_VLM_ALLOW_CPU:
559
+ _game_vlm_error = "cpu-disabled-for-vlm"
560
+ logger.warning("vlm runtime skipped err=%s", _game_vlm_error)
561
+ return None
562
+
563
+ dtype = torch.float16 if device == "cuda" else torch.float32
564
+
565
+ errors: list[str] = []
566
+ for model_name in GAME_VLM_MODELS:
567
+ try:
568
+ processor = AutoProcessor.from_pretrained(model_name)
569
+
570
+ model = None
571
+ load_errors: list[str] = []
572
+ for candidate in (QwenVLMClass, GenericVLMClass):
573
+ if candidate is None:
574
+ continue
575
+ try:
576
+ kwargs: dict[str, Any] = {"torch_dtype": dtype}
577
+ if device == "cuda":
578
+ kwargs["device_map"] = "auto"
579
+ else:
580
+ kwargs["low_cpu_mem_usage"] = True
581
+ model = candidate.from_pretrained(model_name, **kwargs)
582
+ break
583
+ except TypeError:
584
+ try:
585
+ kwargs = {"torch_dtype": dtype}
586
+ model = candidate.from_pretrained(model_name, **kwargs)
587
+ break
588
+ except Exception as exc:
589
+ load_errors.append(str(exc))
590
+ except Exception as exc:
591
+ load_errors.append(str(exc))
592
+
593
+ if model is None:
594
+ raise RuntimeError(" | ".join(load_errors) if load_errors else "vlm-load-failed")
595
+
596
+ if device == "cpu":
597
+ model.to(device)
598
+ model.eval()
599
+
600
+ _game_vlm_runtime = {
601
+ "torch": torch,
602
+ "Image": Image,
603
+ "processor": processor,
604
+ "model": model,
605
+ "device": device,
606
+ "model_name": model_name,
607
+ }
608
+ logger.info("vlm runtime initialized model=%s device=%s", model_name, device)
609
+ return _game_vlm_runtime
610
+ except Exception as exc:
611
+ logger.warning("vlm runtime init failed model=%s err=%s", model_name, str(exc))
612
+ errors.append(f"{model_name}: {exc}")
613
+
614
+ _game_vlm_error = " | ".join(errors) if errors else "vlm-load-failed"
615
+ logger.warning("vlm runtime init failed for all models err=%s", _game_vlm_error)
616
+ return None
617
+ except Exception as exc:
618
+ _game_vlm_error = str(exc)
619
+ logger.warning("vlm runtime init failed err=%s", _game_vlm_error)
620
+ return None
621
+
622
+
623
+ def _run_local_game_vlm_generation(
624
+ runtime: dict[str, Any],
625
+ file_path: str,
626
+ payload: dict[str, Any],
627
+ ) -> str:
628
+ torch = runtime["torch"]
629
+ Image = runtime["Image"]
630
+ processor = runtime["processor"]
631
+ model = runtime["model"]
632
+ device = runtime["device"]
633
+
634
+ prompt = (
635
+ f"{GAME_VLM_PROMPT}\n\n"
636
+ "Input JSON:\n"
637
+ f"{json.dumps(payload, ensure_ascii=True)}\n\n"
638
+ "Return exact JSON only."
639
+ )
640
+
641
+ with Image.open(file_path) as img:
642
+ image = img.convert("RGB")
643
+
644
+ if hasattr(processor, "apply_chat_template"):
645
+ messages = [
646
+ {
647
+ "role": "user",
648
+ "content": [
649
+ {"type": "image", "image": image},
650
+ {"type": "text", "text": prompt},
651
+ ],
652
+ }
653
+ ]
654
+ text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
655
+ inputs = processor(text=[text], images=[image], padding=True, return_tensors="pt")
656
+ else:
657
+ inputs = processor(images=image, text=prompt, return_tensors="pt")
658
+
659
+ if hasattr(inputs, "to"):
660
+ inputs = inputs.to(device)
661
+ else:
662
+ inputs = {k: v.to(device) if hasattr(v, "to") else v for k, v in inputs.items()}
663
+
664
+ with torch.no_grad():
665
+ output_ids = model.generate(
666
+ **inputs,
667
+ max_new_tokens=GAME_VLM_MAX_NEW_TOKENS,
668
+ do_sample=False,
669
+ )
670
+
671
+ if hasattr(inputs, "get") and inputs.get("input_ids") is not None:
672
+ prompt_tokens = int(inputs["input_ids"].shape[1])
673
+ generated = output_ids[0][prompt_tokens:]
674
+ return processor.decode(generated, skip_special_tokens=True).strip()
675
+
676
+ return processor.batch_decode(output_ids, skip_special_tokens=True)[0].strip()
677
+
678
+
679
+ def _classify_game_with_llm(ocr_text: str, urls: list[str], suspected_game: bool) -> dict[str, Any] | None:
680
+ if not ocr_text and not urls:
681
+ return None
682
+
683
+ user_payload = {
684
+ "suspected_game_signal": suspected_game,
685
+ "extracted_urls": urls,
686
+ "ocr_text": ocr_text,
687
+ }
688
+
689
+ runtime = _load_local_game_llm_runtime()
690
+ if runtime is None:
691
+ logger.warning("llm local runtime unavailable; fallback to heuristic")
692
+ return None
693
+
694
+ model_name = str(runtime.get("model_name", LOCAL_GAME_LLM_MODEL))
695
+ try:
696
+ content = _run_local_game_llm_generation(runtime, user_payload)
697
+ except Exception as exc:
698
+ logger.warning("llm local generation failed model=%s err=%s", model_name, repr(exc))
699
+ return None
700
+
701
+ parsed = _parse_llm_json(content)
702
+ if parsed is None:
703
+ logger.warning("llm invalid json model=%s content=%s", model_name, content[:300])
704
+ return None
705
+
706
+ parsed["source"] = f"local-llm:{model_name}"
707
+ return parsed
708
+
709
+
710
+ def _build_game_prompt(user_payload: dict[str, Any]) -> str:
711
+ return (
712
+ f"{GAME_PROMPT}\n\n"
713
+ "Input JSON:\n"
714
+ f"{json.dumps(user_payload, ensure_ascii=True)}\n\n"
715
+ "Return exact JSON only."
716
+ )
717
+
718
+
719
+ def _build_game_messages(user_payload: dict[str, Any]) -> list[dict[str, str]]:
720
+ return [
721
+ {"role": "system", "content": GAME_PROMPT},
722
+ {"role": "user", "content": json.dumps(user_payload, ensure_ascii=True)},
723
+ ]
724
+
725
+
726
+ def _load_local_game_llm_runtime() -> dict[str, Any] | None:
727
+ global _game_llm_runtime
728
+ global _game_llm_error
729
+
730
+ if _game_llm_runtime is not None:
731
+ return _game_llm_runtime
732
+ if _game_llm_error is not None:
733
+ return None
734
+
735
+ try:
736
+ import torch
737
+ from transformers import AutoModelForCausalLM, AutoTokenizer
738
+
739
+ device = "cuda" if torch.cuda.is_available() else "cpu"
740
+ dtype = torch.float16 if device == "cuda" else torch.float32
741
+
742
+ errors: list[str] = []
743
+ for model_name in LOCAL_GAME_LLM_MODELS:
744
+ try:
745
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
746
+
747
+ kwargs: dict[str, Any] = {"torch_dtype": dtype}
748
+ if device == "cuda":
749
+ kwargs["device_map"] = "auto"
750
+ else:
751
+ kwargs["low_cpu_mem_usage"] = True
752
+
753
+ try:
754
+ model = AutoModelForCausalLM.from_pretrained(model_name, **kwargs)
755
+ except TypeError:
756
+ # Older transformers may not support low_cpu_mem_usage/device_map in this path.
757
+ kwargs.pop("device_map", None)
758
+ kwargs.pop("low_cpu_mem_usage", None)
759
+ model = AutoModelForCausalLM.from_pretrained(model_name, **kwargs)
760
+
761
+ if device == "cpu":
762
+ model.to(device)
763
+ model.eval()
764
+
765
+ _game_llm_runtime = {
766
+ "torch": torch,
767
+ "tokenizer": tokenizer,
768
+ "model": model,
769
+ "device": device,
770
+ "model_name": model_name,
771
+ }
772
+ logger.info("llm local runtime initialized model=%s device=%s", model_name, device)
773
+ return _game_llm_runtime
774
+ except Exception as exc:
775
+ logger.warning("llm local runtime init failed model=%s err=%s", model_name, str(exc))
776
+ errors.append(f"{model_name}: {exc}")
777
+
778
+ _game_llm_error = " | ".join(errors) if errors else "no-model-candidate"
779
+ logger.warning("llm local runtime init failed for all models err=%s", _game_llm_error)
780
+ return None
781
+ except Exception as exc:
782
+ _game_llm_error = str(exc)
783
+ logger.warning("llm local runtime import/init failed err=%s", _game_llm_error)
784
+ return None
785
+
786
+
787
+ def _run_local_game_llm_generation(runtime: dict[str, Any], user_payload: dict[str, Any]) -> str:
788
+ torch = runtime["torch"]
789
+ tokenizer = runtime["tokenizer"]
790
+ model = runtime["model"]
791
+ device = runtime["device"]
792
+
793
+ messages = _build_game_messages(user_payload)
794
+ if hasattr(tokenizer, "apply_chat_template"):
795
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
796
+ else:
797
+ prompt = _build_game_prompt(user_payload)
798
+
799
+ inputs = tokenizer(
800
+ prompt,
801
+ return_tensors="pt",
802
+ truncation=True,
803
+ max_length=LOCAL_GAME_LLM_MAX_INPUT_TOKENS,
804
+ )
805
+ inputs = {k: v.to(device) for k, v in inputs.items()}
806
+
807
+ pad_token_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
808
+ with torch.no_grad():
809
+ output_ids = model.generate(
810
+ **inputs,
811
+ max_new_tokens=LOCAL_GAME_LLM_MAX_NEW_TOKENS,
812
+ do_sample=False,
813
+ pad_token_id=pad_token_id,
814
+ eos_token_id=tokenizer.eos_token_id,
815
+ )
816
+
817
+ prompt_tokens = inputs["input_ids"].shape[1]
818
+ generated_ids = output_ids[0][prompt_tokens:]
819
+ return tokenizer.decode(generated_ids, skip_special_tokens=True).strip()
820
+
821
+
822
+ def _parse_llm_json(content: str) -> dict[str, Any] | None:
823
+ if not content:
824
+ return None
825
+
826
+ text = content.strip()
827
+ try:
828
+ data = json.loads(text)
829
+ except json.JSONDecodeError:
830
+ match = re.search(r"\{.*\}", text, re.DOTALL)
831
+ if not match:
832
+ return None
833
+ try:
834
+ data = json.loads(match.group(0))
835
+ except json.JSONDecodeError:
836
+ return None
837
+
838
+ verdict = str(data.get("verdict", "")).lower()
839
+ if verdict not in {"game", "not_game", "uncertain"}:
840
+ return None
841
+
842
+ confidence_raw = data.get("confidence", 0.5)
843
+ try:
844
+ confidence = float(confidence_raw)
845
+ except (TypeError, ValueError):
846
+ confidence = 0.5
847
+
848
+ confidence = max(0.0, min(1.0, confidence))
849
+ raw_reason = str(data.get("reason", "")).strip().lower()
850
+ reason = _normalize_reason_code(verdict, raw_reason)
851
+
852
+ return {"verdict": verdict, "confidence": confidence, "reason": reason}
853
+
854
+
855
+ def _normalize_reason_code(verdict: str, reason: str) -> str:
856
+ if reason in ALLOWED_REASON_CODES:
857
+ return reason
858
+
859
+ if verdict == "game":
860
+ return "game_ui_terms"
861
+ if verdict == "uncertain":
862
+ return "conflicting_signals"
863
+ return "no_game_evidence"
864
+
865
+
866
+ def _domain_from_url(url: str) -> str:
867
+ parsed = urlparse(url)
868
+ host = parsed.netloc or parsed.path
869
+ host = host.lower().replace("www.", "")
870
+ parts = host.split(".")
871
+ if not parts:
872
+ return host
873
+ return parts[0]
874
+
875
+
876
+ def _is_game_like_domain(url: str) -> bool:
877
+ parsed = urlparse(url)
878
+ host = (parsed.netloc or parsed.path).lower().replace("www.", "")
879
+ first_label = _domain_from_url(url)
880
+
881
+ if first_label in GAME_KEYWORDS and first_label not in {"game", "games", "play"}:
882
+ return True
883
+
884
+ if host.endswith(".games"):
885
+ return True
886
+
887
+ # Precision-first: require explicit game-platform/domain hints.
888
+ host_tokens = re.split(r"[^a-z0-9]+", host)
889
+ for token in host_tokens:
890
+ if not token:
891
+ continue
892
+ if token in STRONG_GAME_DOMAIN_HINTS:
893
+ return True
894
+
895
+ return False
896
+
897
+
898
+ def filter_cacheable_game_urls(urls: list[str]) -> list[str]:
899
+ deduped: list[str] = []
900
+ seen: set[str] = set()
901
+ for item in urls:
902
+ if item in seen:
903
+ continue
904
+ seen.add(item)
905
+ if _is_game_like_domain(item):
906
+ deduped.append(item)
907
+ return deduped
908
+
909
+
910
+ def _normalize_ocr_url(value: str) -> str | None:
911
+ item = value.strip().rstrip(".,)").lower()
912
+ if not item:
913
+ return None
914
+ if not item.startswith("http://") and not item.startswith("https://"):
915
+ item = f"https://{item}"
916
+
917
+ parsed = urlparse(item)
918
+ host = (parsed.netloc or "").strip().lower()
919
+ if not host or "." not in host:
920
+ return None
921
+ if "_" in host:
922
+ return None
923
+
924
+ tld = host.rsplit(".", 1)[-1]
925
+ if not tld.isalpha() or len(tld) < 2 or len(tld) > 24:
926
+ return None
927
+ if tld in NON_DOMAIN_TLDS:
928
+ return None
929
+
930
+ if parsed.query:
931
+ return f"{parsed.scheme}://{host}{parsed.path}?{parsed.query}"
932
+ return f"{parsed.scheme}://{host}{parsed.path}"
933
+
934
+
935
+ def _has_any_term(text: str, terms: set[str]) -> bool:
936
+ if not text:
937
+ return False
938
+ lower = text.lower()
939
+ return any(term in lower for term in terms)
940
+
941
+
942
+ def _apply_precision_guard(
943
+ llm_result: dict[str, Any],
944
+ ocr_text: str,
945
+ urls: list[str],
946
+ suspected_game: bool,
947
+ ) -> dict[str, Any]:
948
+ verdict = str(llm_result.get("verdict", "")).lower()
949
+ if verdict != "game":
950
+ return llm_result
951
+
952
+ strong_domain_hit = any(_is_game_like_domain(url) for url in urls)
953
+ strong_ui_hit = _has_any_term(ocr_text, STRONG_GAME_UI_TERMS)
954
+ medium_ui_hit = _has_any_term(ocr_text, MEDIUM_GAME_UI_TERMS)
955
+
956
+ if strong_domain_hit or strong_ui_hit or (suspected_game and medium_ui_hit):
957
+ return llm_result
958
+
959
+ confidence = float(llm_result.get("confidence", 0.5))
960
+ return {
961
+ "verdict": "not_game",
962
+ "confidence": min(confidence, 0.35),
963
+ "reason": "no_game_evidence",
964
+ "source": "rule-guard",
965
+ }
966
+
967
+
968
+ def classify_sensitive_content(file_path: str, filename: str) -> tuple[bool, float, str]:
969
+ # First pass with image model inference; fallback to keyword signal only if unavailable.
970
+ model_result = _classify_sensitive_with_model(file_path)
971
+ if model_result is not None:
972
+ return model_result
973
+
974
+ name_text = f"{Path(file_path).name} {filename}".lower()
975
+ keyword_hit = any(word in name_text for word in SENSITIVE_KEYWORDS)
976
+ if keyword_hit:
977
+ return True, 0.65, "sensitive-keyword-fallback"
978
+ return False, 0.05, "no-sensitive-signal"
979
+
980
+
981
+ def _load_nsfw_runtime() -> dict[str, Any] | None:
982
+ global _nsfw_runtime
983
+ global _nsfw_error
984
+
985
+ if _nsfw_runtime is not None:
986
+ return _nsfw_runtime
987
+ if _nsfw_error is not None:
988
+ return None
989
+
990
+ try:
991
+ import timm
992
+ import torch
993
+ from PIL import Image
994
+ from safetensors.torch import load_file
995
+
996
+ if not NSFW_CONFIG_PATH.exists() or not NSFW_WEIGHTS_PATH.exists():
997
+ _nsfw_error = "missing-local-model-files"
998
+ return None
999
+
1000
+ config_data = json.loads(NSFW_CONFIG_PATH.read_text(encoding="utf-8"))
1001
+ architecture = str(config_data.get("architecture", "vit_tiny_patch16_384"))
1002
+ num_classes = int(config_data.get("num_classes", 2))
1003
+ label_names = [str(x).lower() for x in config_data.get("label_names", ["nsfw", "sfw"])]
1004
+ pretrained_cfg = config_data.get("pretrained_cfg", {})
1005
+
1006
+ model = timm.create_model(architecture, pretrained=False, num_classes=num_classes).eval()
1007
+ state_dict = load_file(str(NSFW_WEIGHTS_PATH), device="cpu")
1008
+ model.load_state_dict(state_dict, strict=False)
1009
+
1010
+ # Use local config for preprocessing so inference does not depend on remote metadata.
1011
+ model.pretrained_cfg = {**getattr(model, "pretrained_cfg", {}), **pretrained_cfg, "label_names": label_names}
1012
+
1013
+ data_config = timm.data.resolve_model_data_config(model)
1014
+ transforms = timm.data.create_transform(**data_config, is_training=False)
1015
+
1016
+ _nsfw_runtime = {
1017
+ "torch": torch,
1018
+ "Image": Image,
1019
+ "model": model,
1020
+ "transforms": transforms,
1021
+ "label_names": [str(x).lower() for x in label_names],
1022
+ }
1023
+ return _nsfw_runtime
1024
+ except Exception as exc:
1025
+ _nsfw_error = str(exc)
1026
+ return None
1027
+
1028
+
1029
+ def _classify_sensitive_with_model(file_path: str) -> tuple[bool, float, str] | None:
1030
+ runtime = _load_nsfw_runtime()
1031
+ if runtime is None:
1032
+ return None
1033
+
1034
+ torch = runtime["torch"]
1035
+ Image = runtime["Image"]
1036
+ model = runtime["model"]
1037
+ transforms = runtime["transforms"]
1038
+ label_names = runtime["label_names"]
1039
+
1040
+ with Image.open(file_path) as img:
1041
+ img = img.convert("RGB")
1042
+ with torch.no_grad():
1043
+ output = model(transforms(img).unsqueeze(0)).softmax(dim=-1).cpu()[0]
1044
+
1045
+ scores = [float(x) for x in output.tolist()]
1046
+ nsfw_score = _extract_nsfw_score(scores, label_names)
1047
+ return (nsfw_score >= NSFW_THRESHOLD, nsfw_score, "timm-marqo-nsfw")
1048
+
1049
+
1050
+ def _extract_nsfw_score(scores: list[float], labels: list[str]) -> float:
1051
+ for idx, label in enumerate(labels):
1052
+ if "nsfw" in label:
1053
+ return scores[idx]
1054
+ if len(scores) >= 2:
1055
+ return scores[1]
1056
+ return scores[0] if scores else 0.0
services/key_service.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ from hashlib import sha256
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from app.services.repos import key_state_repo
6
+
7
+
8
+ def _hash_key(raw: str) -> str:
9
+ return sha256(raw.encode("utf-8")).hexdigest()
10
+
11
+
12
+ def _to_utc_aware(value: datetime | None) -> datetime | None:
13
+ if not isinstance(value, datetime):
14
+ return None
15
+ if value.tzinfo is None:
16
+ return value.replace(tzinfo=timezone.utc)
17
+ return value.astimezone(timezone.utc)
18
+
19
+
20
+ class KeyService:
21
+ def __init__(self, *, ttl_seconds: int = 60, lock_minutes: int = 5) -> None:
22
+ self.ttl_seconds = ttl_seconds
23
+ self.lock_minutes = lock_minutes
24
+
25
+ async def generate(self, device_id: str) -> tuple[str, datetime]:
26
+ key = secrets.token_urlsafe(8)
27
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=self.ttl_seconds)
28
+ await key_state_repo.upsert(
29
+ device_id,
30
+ {
31
+ "key_hash": _hash_key(key),
32
+ "expires_at": expires_at,
33
+ "wrong_attempts": 0,
34
+ "lock_until": None,
35
+ },
36
+ )
37
+ return key, expires_at
38
+
39
+ async def validate(self, device_id: str, candidate: str) -> tuple[bool, datetime | None, int]:
40
+ state = await key_state_repo.get(device_id)
41
+ now = datetime.now(timezone.utc)
42
+
43
+ if state is None:
44
+ lock_until = now + timedelta(minutes=self.lock_minutes)
45
+ await key_state_repo.upsert(
46
+ device_id,
47
+ {
48
+ "wrong_attempts": 1,
49
+ "lock_until": lock_until,
50
+ "expires_at": now,
51
+ "key_hash": "",
52
+ },
53
+ )
54
+ return False, lock_until, 1
55
+
56
+ lock_until_value = _to_utc_aware(state.get("lock_until"))
57
+ if lock_until_value is not None and now < lock_until_value:
58
+ return False, lock_until_value, int(state.get("wrong_attempts", 0))
59
+
60
+ expires_at = _to_utc_aware(state.get("expires_at"))
61
+ if expires_at is None or now > expires_at:
62
+ lock_until = now + timedelta(minutes=self.lock_minutes)
63
+ attempts = int(state.get("wrong_attempts", 0)) + 1
64
+ await key_state_repo.upsert(
65
+ device_id,
66
+ {
67
+ "wrong_attempts": attempts,
68
+ "lock_until": lock_until,
69
+ "expires_at": now,
70
+ },
71
+ )
72
+ return False, lock_until, attempts
73
+
74
+ candidate_hash = _hash_key(candidate)
75
+ expected_hash = str(state.get("key_hash", ""))
76
+ if not secrets.compare_digest(candidate_hash, expected_hash):
77
+ attempts = int(state.get("wrong_attempts", 0)) + 1
78
+ lock_until = now + timedelta(minutes=self.lock_minutes)
79
+ await key_state_repo.upsert(
80
+ device_id,
81
+ {
82
+ "wrong_attempts": attempts,
83
+ "lock_until": lock_until,
84
+ },
85
+ )
86
+ return False, lock_until, attempts
87
+
88
+ await key_state_repo.upsert(
89
+ device_id,
90
+ {
91
+ "wrong_attempts": 0,
92
+ "lock_until": None,
93
+ "expires_at": now,
94
+ "key_hash": "",
95
+ },
96
+ )
97
+ return True, None, 0
98
+
99
+
100
+ key_service = KeyService()
services/policy_window.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ _DAY_INDEX = {
5
+ "mon": 0,
6
+ "tue": 1,
7
+ "wed": 2,
8
+ "thu": 3,
9
+ "fri": 4,
10
+ "sat": 5,
11
+ "sun": 6,
12
+ }
13
+
14
+
15
+ def _parse_hhmm(value: str) -> tuple[int, int]:
16
+ hour_text, minute_text = value.split(":", 1)
17
+ return int(hour_text), int(minute_text)
18
+
19
+
20
+ def _minutes_of_day(moment: datetime) -> int:
21
+ return moment.hour * 60 + moment.minute
22
+
23
+
24
+ def is_restriction_active(windows: list[dict[str, Any]], moment: datetime) -> bool:
25
+ if not windows:
26
+ return False
27
+
28
+ weekday = moment.weekday()
29
+ now_minutes = _minutes_of_day(moment)
30
+
31
+ for window in windows:
32
+ day = window.get("day_of_week")
33
+ start = str(window.get("start", "00:00"))
34
+ end = str(window.get("end", "00:00"))
35
+ if day not in _DAY_INDEX:
36
+ continue
37
+
38
+ start_h, start_m = _parse_hhmm(start)
39
+ end_h, end_m = _parse_hhmm(end)
40
+ start_minutes = start_h * 60 + start_m
41
+ end_minutes = end_h * 60 + end_m
42
+ window_day = _DAY_INDEX[day]
43
+
44
+ if start_minutes <= end_minutes:
45
+ if weekday == window_day and start_minutes <= now_minutes <= end_minutes:
46
+ return True
47
+ continue
48
+
49
+ # Overnight window: e.g. 22:00 -> 02:00
50
+ if weekday == window_day and now_minutes >= start_minutes:
51
+ return True
52
+ if weekday == (window_day + 1) % 7 and now_minutes <= end_minutes:
53
+ return True
54
+
55
+ return False
services/repos.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from app.core.db import get_db
5
+
6
+
7
+ def _clean(doc: dict[str, Any] | None) -> dict[str, Any] | None:
8
+ if doc is None:
9
+ return None
10
+ doc.pop("_id", None)
11
+ return doc
12
+
13
+
14
+ class DeviceRepo:
15
+ async def upsert(self, data: dict[str, Any]) -> None:
16
+ db = get_db()
17
+ await db.devices.update_one(
18
+ {"device_id": data["device_id"]},
19
+ {"$set": {**data, "updated_at": datetime.now(timezone.utc)}},
20
+ upsert=True,
21
+ )
22
+
23
+ async def list(self) -> list[dict[str, Any]]:
24
+ db = get_db()
25
+ rows = await db.devices.find({}, {"_id": 0}).to_list(length=200)
26
+ return rows
27
+
28
+ async def heartbeat(self, device_id: str, active_restriction_window: bool, uptime_seconds: int) -> None:
29
+ db = get_db()
30
+ now = datetime.now(timezone.utc)
31
+ await db.devices.update_one(
32
+ {"device_id": device_id},
33
+ {
34
+ "$set": {
35
+ "device_id": device_id,
36
+ "active_restriction_window": active_restriction_window,
37
+ "uptime_seconds": uptime_seconds,
38
+ "last_heartbeat_at": now,
39
+ "updated_at": now,
40
+ }
41
+ },
42
+ upsert=True,
43
+ )
44
+
45
+
46
+ class PolicyRepo:
47
+ async def set(self, device_id: str, policy: dict[str, Any]) -> None:
48
+ db = get_db()
49
+ await db.policies.update_one(
50
+ {"device_id": device_id},
51
+ {
52
+ "$set": {
53
+ "device_id": device_id,
54
+ "policy": policy,
55
+ "updated_at": datetime.now(timezone.utc),
56
+ }
57
+ },
58
+ upsert=True,
59
+ )
60
+
61
+ async def get(self, device_id: str) -> dict[str, Any] | None:
62
+ db = get_db()
63
+ doc = await db.policies.find_one({"device_id": device_id}, {"_id": 0})
64
+ return _clean(doc)
65
+
66
+
67
+ class ViolationRepo:
68
+ async def insert(self, doc: dict[str, Any]) -> None:
69
+ db = get_db()
70
+ await db.violations.insert_one(doc)
71
+
72
+ async def list_active(self, visibility_scope: str) -> list[dict[str, Any]]:
73
+ db = get_db()
74
+ now = datetime.now(timezone.utc)
75
+ query = {"expires_at": {"$gt": now}, "visibility_scope": visibility_scope}
76
+ rows = (
77
+ await db.violations.find(query, {"_id": 0})
78
+ .sort("timestamp", -1)
79
+ .to_list(length=500)
80
+ )
81
+ return rows
82
+
83
+ async def list_expired(self) -> list[dict[str, Any]]:
84
+ db = get_db()
85
+ now = datetime.now(timezone.utc)
86
+ return await db.violations.find({"expires_at": {"$lte": now}}).to_list(length=500)
87
+
88
+ async def delete_expired(self) -> int:
89
+ db = get_db()
90
+ now = datetime.now(timezone.utc)
91
+ result = await db.violations.delete_many({"expires_at": {"$lte": now}})
92
+ return result.deleted_count
93
+
94
+
95
+ class AuditRepo:
96
+ async def log(self, doc: dict[str, Any]) -> None:
97
+ db = get_db()
98
+ await db.screenshot_audit.insert_one(doc)
99
+
100
+
101
+ class KeyStateRepo:
102
+ async def get(self, device_id: str) -> dict[str, Any] | None:
103
+ db = get_db()
104
+ doc = await db.key_states.find_one({"device_id": device_id})
105
+ return _clean(doc)
106
+
107
+ async def upsert(self, device_id: str, patch: dict[str, Any]) -> None:
108
+ db = get_db()
109
+ await db.key_states.update_one(
110
+ {"device_id": device_id},
111
+ {
112
+ "$set": {
113
+ **patch,
114
+ "device_id": device_id,
115
+ "updated_at": datetime.now(timezone.utc),
116
+ }
117
+ },
118
+ upsert=True,
119
+ )
120
+
121
+
122
+ class UrlVerdictRepo:
123
+ async def get_cached_game_url(self, urls: list[str]) -> str | None:
124
+ if not urls:
125
+ return None
126
+ db = get_db()
127
+ doc = await db.url_verdicts.find_one(
128
+ {"url": {"$in": urls}, "verdict": "game"},
129
+ {"_id": 0, "url": 1},
130
+ )
131
+ if not doc:
132
+ return None
133
+ return str(doc.get("url", ""))
134
+
135
+ async def upsert_game_urls(self, urls: list[str], source: str) -> None:
136
+ if not urls:
137
+ return
138
+ db = get_db()
139
+ now = datetime.now(timezone.utc)
140
+ for url in urls:
141
+ await db.url_verdicts.update_one(
142
+ {"url": url},
143
+ {
144
+ "$set": {
145
+ "url": url,
146
+ "verdict": "game",
147
+ "source": source,
148
+ "updated_at": now,
149
+ }
150
+ },
151
+ upsert=True,
152
+ )
153
+
154
+
155
+ class ReviewRepo:
156
+ async def create_pending(self, doc: dict[str, Any]) -> None:
157
+ db = get_db()
158
+ await db.game_lock_reviews.insert_one(doc)
159
+
160
+ async def list_pending(self, limit: int = 200) -> list[dict[str, Any]]:
161
+ db = get_db()
162
+ rows = (
163
+ await db.game_lock_reviews.find({"status": "pending"}, {"_id": 0})
164
+ .sort("created_at", -1)
165
+ .to_list(length=limit)
166
+ )
167
+ return rows
168
+
169
+ async def decide(self, review_id: str, approve_lock: bool, decided_by: str) -> bool:
170
+ db = get_db()
171
+ result = await db.game_lock_reviews.update_one(
172
+ {"review_id": review_id, "status": "pending"},
173
+ {
174
+ "$set": {
175
+ "status": "approved" if approve_lock else "rejected",
176
+ "decided_by": decided_by,
177
+ "decided_at": datetime.now(timezone.utc),
178
+ }
179
+ },
180
+ )
181
+ return result.modified_count > 0
182
+
183
+
184
+ device_repo = DeviceRepo()
185
+ policy_repo = PolicyRepo()
186
+ violation_repo = ViolationRepo()
187
+ audit_repo = AuditRepo()
188
+ key_state_repo = KeyStateRepo()
189
+ url_verdict_repo = UrlVerdictRepo()
190
+ review_repo = ReviewRepo()
services/storage.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from uuid import uuid4
3
+
4
+ from fastapi import UploadFile
5
+
6
+ from app.core.config import settings
7
+
8
+
9
+ class StorageService:
10
+ def __init__(self, base_dir: str) -> None:
11
+ self.base_dir = Path(base_dir)
12
+ self.base_dir.mkdir(parents=True, exist_ok=True)
13
+
14
+ async def save_upload(self, file: UploadFile) -> tuple[str, str, str]:
15
+ extension = Path(file.filename or "capture.png").suffix or ".png"
16
+ image_id = str(uuid4())
17
+ stored_name = f"{image_id}{extension}"
18
+ target_path = self.base_dir / stored_name
19
+
20
+ content = await file.read()
21
+ target_path.write_bytes(content)
22
+ return image_id, str(target_path), stored_name
23
+
24
+ def delete(self, absolute_path: str) -> None:
25
+ target = Path(absolute_path)
26
+ if target.exists():
27
+ target.unlink()
28
+
29
+
30
+ storage_service = StorageService(settings.storage_dir)