Skip to content

Commit 44ca9e5

Browse files
committed
Enable .pt bot runtime and reduce UI polling load
1 parent 3625858 commit 44ca9e5

6 files changed

Lines changed: 181 additions & 6 deletions

File tree

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ COPY pyproject.toml uv.lock README.md ./
2828
# Install only API runtime dependencies; the training stack (torch/cuda) is not
2929
# needed for the web service and was causing Railway build timeouts.
3030
RUN uv sync --frozen --no-install-project --only-group api
31+
# The API can run model bots from `.pt` checkpoints; install CPU-only torch in
32+
# the API venv so Railway does not need CUDA runtimes.
33+
RUN uv pip install --python /app/.venv/bin/python --index-url https://download.pytorch.org/whl/cpu torch
3134

3235

3336
FROM python:3.11-slim AS runtime

scripts/bootstrap_model_bot.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
import sys
6+
from pathlib import Path
7+
8+
from sqlmodel import col, select
9+
10+
# Permite ejecutar el script desde la raiz del repo.
11+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12+
13+
14+
def _parse_args() -> argparse.Namespace:
15+
parser = argparse.ArgumentParser(
16+
description="Crea/actualiza una cuenta bot de modelo y la deja visible en ladder.",
17+
)
18+
parser.add_argument("--username", default="ub_bogonet_v0")
19+
parser.add_argument("--model-version-name", default="ub_bogonet_v0_iter039")
20+
parser.add_argument("--hf-repo-id", default="dieg0code/ataxx-zero")
21+
parser.add_argument("--hf-revision", default="main")
22+
parser.add_argument("--checkpoint-uri", default="hf://dieg0code/ataxx-zero/model_iter_039.pt")
23+
parser.add_argument("--onnx-uri", default="")
24+
parser.add_argument("--model-mode", choices=["fast", "strong"], default="fast")
25+
parser.add_argument("--activate-version", action="store_true")
26+
return parser.parse_args()
27+
28+
29+
async def _ensure_model_bot(args: argparse.Namespace) -> None:
30+
from api.db.enums import AgentType, BotKind
31+
from api.db.models import BotProfile, ModelVersion, User
32+
from api.db.session import get_engine, get_sessionmaker
33+
from api.modules.ranking.repository import RankingRepository
34+
from api.modules.ranking.service import RankingService
35+
36+
sessionmaker = get_sessionmaker()
37+
async with sessionmaker() as session:
38+
version_stmt = select(ModelVersion).where(
39+
col(ModelVersion.name) == args.model_version_name
40+
)
41+
version = (await session.execute(version_stmt)).scalars().first()
42+
if version is None:
43+
version = ModelVersion(
44+
name=args.model_version_name,
45+
hf_repo_id=args.hf_repo_id,
46+
hf_revision=args.hf_revision,
47+
checkpoint_uri=args.checkpoint_uri,
48+
onnx_uri=(args.onnx_uri or None),
49+
is_active=bool(args.activate_version),
50+
notes="Bot bootstrap script.",
51+
)
52+
session.add(version)
53+
await session.commit()
54+
await session.refresh(version)
55+
else:
56+
version.hf_repo_id = args.hf_repo_id
57+
version.hf_revision = args.hf_revision
58+
version.checkpoint_uri = args.checkpoint_uri
59+
version.onnx_uri = args.onnx_uri or None
60+
if args.activate_version:
61+
version.is_active = True
62+
session.add(version)
63+
await session.commit()
64+
await session.refresh(version)
65+
66+
if args.activate_version:
67+
await session.execute(
68+
# Keep one global active version when requested explicitly.
69+
ModelVersion.__table__.update()
70+
.where(col(ModelVersion.id) != version.id)
71+
.values(is_active=False)
72+
)
73+
await session.execute(
74+
ModelVersion.__table__.update()
75+
.where(col(ModelVersion.id) == version.id)
76+
.values(is_active=True)
77+
)
78+
await session.commit()
79+
80+
user_stmt = select(User).where(col(User.username) == args.username)
81+
user = (await session.execute(user_stmt)).scalars().first()
82+
if user is None:
83+
user = User(
84+
username=args.username,
85+
email=f"{args.username}@bots.local",
86+
is_active=True,
87+
is_admin=False,
88+
is_bot=True,
89+
bot_kind=BotKind.MODEL,
90+
is_hidden_bot=False,
91+
model_version_id=version.id,
92+
)
93+
session.add(user)
94+
await session.commit()
95+
await session.refresh(user)
96+
97+
user.is_active = True
98+
user.is_bot = True
99+
user.is_hidden_bot = False
100+
user.bot_kind = BotKind.MODEL
101+
user.model_version_id = version.id
102+
session.add(user)
103+
await session.commit()
104+
await session.refresh(user)
105+
106+
profile_stmt = select(BotProfile).where(col(BotProfile.user_id) == user.id)
107+
profile = (await session.execute(profile_stmt)).scalars().first()
108+
if profile is None:
109+
profile = BotProfile(
110+
user_id=user.id,
111+
agent_type=AgentType.MODEL,
112+
model_mode=args.model_mode,
113+
enabled=True,
114+
)
115+
profile.agent_type = AgentType.MODEL
116+
profile.model_mode = args.model_mode
117+
profile.heuristic_level = None
118+
profile.enabled = True
119+
session.add(profile)
120+
await session.commit()
121+
await session.refresh(profile)
122+
123+
ranking_service = RankingService(ranking_repository=RankingRepository(session=session))
124+
season = await ranking_service.get_active_season()
125+
if season is None:
126+
raise RuntimeError("No active season found. Run scripts/bootstrap_active_season.py first.")
127+
rating = await ranking_service.get_or_create_rating(user.id, season.id)
128+
entries = await ranking_service.recompute_leaderboard(season_id=season.id, limit=500)
129+
rank = next((entry.rank for entry in entries if entry.user_id == user.id), None)
130+
131+
print("Model bot ready:")
132+
print(f" user_id={user.id}")
133+
print(f" username={user.username}")
134+
print(f" model_version_id={version.id}")
135+
print(f" checkpoint_uri={version.checkpoint_uri}")
136+
print(f" model_mode={profile.model_mode}")
137+
print(f" season_id={season.id}")
138+
print(f" rating={rating.rating:.1f}")
139+
print(f" rank={rank}")
140+
141+
await get_engine().dispose()
142+
143+
144+
def main() -> None:
145+
args = _parse_args()
146+
asyncio.run(_ensure_model_bot(args))
147+
148+
149+
if __name__ == "__main__":
150+
main()

src/api/modules/matches/router.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,8 @@ async def invitations_ws(
233233
}
234234
)
235235
try:
236-
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
236+
# Lower polling pressure on DB while still keeping invitation UI responsive.
237+
await asyncio.wait_for(websocket.receive_text(), timeout=2.5)
237238
except (TimeoutError, asyncio.TimeoutError):
238239
continue
239240
except (WebSocketDisconnect, asyncio.TimeoutError):

web/src/features/matches/useInvitations.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,26 @@ export function useInvitations({
3636
const queryClient = useQueryClient();
3737
const [liveInvitations, setLiveInvitations] = useState<HumanInvitation[] | null>(null);
3838
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
39+
const [socketHealthy, setSocketHealthy] = useState(false);
3940

4041
const invitationsQuery = useQuery({
4142
queryKey: ["invitations", scope, accessToken],
4243
queryFn: () => fetchIncomingInvitations(accessToken!, 12, 0),
4344
enabled: enabled && includeInitialFetch && accessToken !== null,
45+
// Poll only while websocket fallback is degraded; this keeps UI fresh
46+
// without hammering the API every few seconds during healthy socket sessions.
4447
refetchInterval:
45-
enabled && includeInitialFetch && fallbackPollingMs > 0
48+
enabled && includeInitialFetch && fallbackPollingMs > 0 && !socketHealthy
4649
? fallbackPollingMs
4750
: false,
4851
refetchIntervalInBackground: true,
52+
staleTime: 8_000,
4953
});
5054

5155
useEffect(() => {
5256
if (!enabled || accessToken === null) {
5357
setLiveInvitations(null);
58+
setSocketHealthy(false);
5459
return;
5560
}
5661
let cancelled = false;
@@ -80,11 +85,13 @@ export function useInvitations({
8085
if (event.type !== "invitations.status") {
8186
return;
8287
}
88+
setSocketHealthy(true);
8389
const pendingItems = event.payload.items.filter((item) => item.status === "pending");
8490
setLiveInvitations(pendingItems);
8591
};
8692

8793
const onSocketFailure = (): void => {
94+
setSocketHealthy(false);
8895
setLiveInvitations(null);
8996
if (includeInitialFetch) {
9097
void queryClient.invalidateQueries({ queryKey: ["invitations", scope, accessToken] });
@@ -97,6 +104,9 @@ export function useInvitations({
97104
return;
98105
}
99106
socket = openInvitationsSocket(accessToken, onEvent);
107+
socket.onopen = () => {
108+
setSocketHealthy(true);
109+
};
100110
socket.onerror = () => {
101111
onSocketFailure();
102112
};

web/src/pages/match/MatchPage.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const AI_THINK_DELAY_MS = 460;
6262
const AI_PREVIEW_MS = 420;
6363
const INFECTION_STEP_MS = 90;
6464
const INFECTION_BURST_MS = 420;
65+
const OUTGOING_INVITE_POLL_MS = 2500;
66+
const UI_TICK_MS = 120;
6567
const INTRO_COUNTDOWN_START = 3;
6668
const HOVER_SFX_MIN_GAP_MS = 120;
6769
const P2_MOVE_SFX_MIN_GAP_MS = 70;
@@ -1095,7 +1097,7 @@ export function MatchPage(): JSX.Element {
10951097
// Keep polling; temporary network errors should not close invitation flow.
10961098
}
10971099
})();
1098-
}, 1200);
1100+
}, OUTGOING_INVITE_POLL_MS);
10991101
return () => {
11001102
cancelled = true;
11011103
window.clearInterval(intervalId);
@@ -1188,10 +1190,19 @@ export function MatchPage(): JSX.Element {
11881190
}
11891191
}, [selectedP2Bot]);
11901192

1193+
const needsUiTicker =
1194+
matchStarted ||
1195+
previewMove !== null ||
1196+
infectionBursts.length > 0 ||
1197+
Object.keys(infectionMask).length > 0;
1198+
11911199
useEffect(() => {
1192-
const interval = window.setInterval(() => setNowMs(Date.now()), 40);
1200+
if (!needsUiTicker) {
1201+
return;
1202+
}
1203+
const interval = window.setInterval(() => setNowMs(Date.now()), UI_TICK_MS);
11931204
return () => window.clearInterval(interval);
1194-
}, []);
1205+
}, [needsUiTicker]);
11951206

11961207
useEffect(() => {
11971208
if (!matchStarted || !showIntro) {

web/src/widgets/layout/AppShell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function AppShell({ children, onNavigateAttempt, onLogoutAttempt }: AppSh
124124
enabled: isAuthenticated,
125125
includeInitialFetch: true,
126126
scope: "appshell",
127-
fallbackPollingMs: 3000,
127+
fallbackPollingMs: 8000,
128128
});
129129
const incomingInvitations = pendingInvitations.length;
130130
const sortedInvitations = useMemo(

0 commit comments

Comments
 (0)