Fix remote training and model validation pipeline

This commit is contained in:
Курнат Андрей
2026-07-12 22:56:49 +03:00
parent 18936cf8b1
commit da53483164
22 changed files with 1691 additions and 246 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 18
versionName = "0.3.0"
versionCode = 20
versionName = "0.4.1"
}
}
@@ -16,7 +16,14 @@ class AppPrefs(context: Context) {
if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) {
prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply()
}
if (prefs.getString("training_computer_name", null).isNullOrBlank()) {
val trainingComputerName = prefs.getString("training_computer_name", null)?.trim()
val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim()
if (
trainingComputerName.isNullOrBlank() ||
trainingComputerName == LEGACY_TRAINING_COMPUTER_NAME ||
trainingComputerPath.isNullOrBlank() ||
trainingComputerPath == LEGACY_TRAINING_COMPUTER_PATH
) {
pinDefaultTrainingComputer()
}
}
@@ -134,8 +141,10 @@ class AppPrefs(context: Context) {
private companion object {
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
const val DEFAULT_TRAINING_COMPUTER_NAME = "SEVENHILL"
const val DEFAULT_TRAINING_COMPUTER_PATH = "G:\\Repos\\TradeBot"
const val LEGACY_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val LEGACY_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
}
}
@@ -489,21 +489,19 @@ class MainActivity : Activity() {
liveSignature = liveBlockSignature(snapshot),
)
val (savedLogin, savedPassword) = authParts(prefs.commandToken)
val apiInput = input("Адрес API бота", prefs.apiBaseUrl)
val loginInput = input("Логин API", savedLogin)
val passwordInput = input("Пароль API", savedPassword).apply {
val tokenInput = input("API-токен", prefs.commandToken).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
box.addView(section("Подключение", LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(apiInput)
addView(loginInput.top(dp(8)))
addView(passwordInput.top(dp(8)))
addView(tokenInput.top(dp(8)))
addView(text("Токен выдаётся сервером для Android-клиента и хранится в Android Keystore.", 12f, Typeface.NORMAL, palette.muted).top(dp(8)))
addView(actionRow(
"Сохранить" to {
prefs.apiBaseUrl = apiInput.text.toString()
prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
prefs.commandToken = tokenInput.text.toString()
toast("Подключение сохранено")
refreshData(silent = false)
},
@@ -1020,6 +1018,13 @@ class MainActivity : Activity() {
LinearLayout(this).apply {
val coordination = retrain.optJSONObject("coordination") ?: JSONObject()
val activeJob = coordination.optJSONObject("active_job")
val worker = coordination.optJSONObject("worker")
val trainingComputerName = worker?.optStringClean("name")
?.takeIf { it.isNotBlank() }
?: prefs.trainingComputerName
val trainingComputerPath = worker?.optStringClean("path")
?.takeIf { it.isNotBlank() }
?: prefs.trainingComputerPath
val agentRecentlySeen = coordination.optBoolean(
"agent_recently_seen",
coordination.optBoolean("agent_online", false),
@@ -1036,8 +1041,8 @@ class MainActivity : Activity() {
val connectionColor = if (agentRecentlySeen || agentBusy) palette.green else palette.amber
orientation = LinearLayout.VERTICAL
addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted))
addView(text(prefs.trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5)))
addView(text(prefs.trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4)))
addView(text(trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5)))
addView(text(trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4)))
addView(keyValueLine("Связь агента", connectionText, connectionColor).top(dp(4)))
addView(text("Бот ставит задания через tb.kusoft.xyz, а этот Windows-agent сам забирает их через интернет и возвращает результат.", 12f, Typeface.NORMAL, palette.muted).top(dp(8)))
}
@@ -1052,10 +1057,9 @@ class MainActivity : Activity() {
val agentOnline = coordination.optBoolean("agent_online", false)
addView(keyValueLine("Состояние", if (agentOnline) "готов к запуску" else "ждет Windows-agent", if (agentOnline) palette.green else palette.amber).top(dp(8)))
if (latestJob != null) {
val latestStatus = latestJob.optStringClean("status")
val latestPhase = latestJob.optStringClean("phase")
val latestMessage = latestJob.optStringClean("message")
addView(keyValueLine("Последнее обучение", trainingJobLabel(latestStatus, latestPhase), trainingJobColor(latestStatus, latestPhase)).top(dp(8)))
addView(keyValueLine("Последнее обучение", trainingJobLabel(latestJob), trainingJobColor(latestJob)).top(dp(8)))
addView(trainingModelDecisionLine(latestJob).top(dp(6)))
if (latestMessage.isNotBlank()) {
addView(text(latestMessage.take(160), 12f, Typeface.NORMAL, palette.muted).top(dp(6)))
}
@@ -1064,12 +1068,11 @@ class MainActivity : Activity() {
}
val job = activeJob
val status = job.optStringClean("status")
val phase = job.optStringClean("phase")
val progress = job.optInt("progress_percent", if (status == "completed") 100 else 0).coerceIn(0, 100)
val message = job.optStringClean("message")
addView(keyValueLine("Состояние", trainingJobLabel(status, phase), trainingJobColor(status, phase)).top(dp(8)))
addView(keyValueLine("Состояние", trainingJobLabel(job), trainingJobColor(job)).top(dp(8)))
addView(allocationBar(progress / 100.0).top(dp(10)))
addView(keyValueLine("Прогресс", "$progress%", trainingJobColor(status, phase)).top(dp(8)))
addView(keyValueLine("Прогресс", "$progress%", trainingJobColor(job)).top(dp(8)))
if (message.isNotBlank()) {
addView(text(message.take(160), 12f, Typeface.NORMAL, palette.text).top(dp(8)))
}
@@ -1241,22 +1244,19 @@ class MainActivity : Activity() {
if (!isAuthError()) {
return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}")
}
val (savedLogin, savedPassword) = authParts(prefs.commandToken)
val apiInput = input("Адрес API бота", prefs.apiBaseUrl)
val loginInput = input("Логин API", savedLogin)
val passwordInput = input("Пароль API", savedPassword).apply {
val tokenInput = input("API-токен", prefs.commandToken).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
return section("Нужен вход в API", LinearLayout(this).apply {
return section("Нужен API-токен", LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите логин и пароль, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted))
addView(text("Сервер доступен, но Android API-токен отсутствует или неверен. Введите отдельный токен клиента; логин и пароль сайта здесь не используются.", 13f, Typeface.NORMAL, palette.muted))
addView(apiInput.top(dp(12)))
addView(loginInput.top(dp(8)))
addView(passwordInput.top(dp(8)))
addView(tokenInput.top(dp(8)))
addView(actionRow(
"Подключиться" to {
prefs.apiBaseUrl = apiInput.text.toString()
prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
prefs.commandToken = tokenInput.text.toString()
toast("Доступ сохранен, проверяю API")
refreshData(silent = false)
},
@@ -1642,11 +1642,40 @@ class MainActivity : Activity() {
},
).joinToString("|")
private fun trainingJobLabel(status: String, phase: String): String =
when (status) {
private fun trainingModelDecision(job: JSONObject): String {
val explicit = job.optStringClean("model_decision")
if (explicit in setOf("accepted", "rejected")) return explicit
val summary = job.optJSONObject("summary") ?: return ""
return if (summary.has("accepted")) {
if (summary.optBoolean("accepted", false)) "accepted" else "rejected"
} else {
""
}
}
private fun trainingModelDecisionLine(job: JSONObject): View {
val decision = trainingModelDecision(job)
val value = when (decision) {
"accepted" -> "кандидат принят"
"rejected" -> "кандидат отклонён"
else -> "нет решения gate"
}
val color = when (decision) {
"accepted" -> palette.green
"rejected" -> palette.amber
else -> palette.muted
}
return keyValueLine("Результат модели", value, color)
}
private fun trainingJobLabel(job: JSONObject): String {
val status = job.optStringClean("status")
val phase = job.optStringClean("phase")
val completedWithDecision = trainingModelDecision(job).isNotBlank()
return when (status) {
"pending" -> "ждет Windows-agent"
"completed" -> "завершено успешно"
"failed" -> "ошибка обучения"
"failed" -> if (completedWithDecision) "завершено успешно" else "ошибка обучения"
"running" -> when (phase) {
"claimed" -> "задание получено"
"training" -> "идет обучение"
@@ -1656,14 +1685,19 @@ class MainActivity : Activity() {
}
else -> "готов к запуску"
}
}
private fun trainingJobColor(status: String, phase: String): Int =
when {
status == "completed" -> palette.green
private fun trainingJobColor(job: JSONObject): Int {
val status = job.optStringClean("status")
val phase = job.optStringClean("phase")
val completedWithDecision = trainingModelDecision(job).isNotBlank()
return when {
status == "completed" || completedWithDecision -> palette.green
status == "failed" -> palette.red
status == "pending" || status == "running" || phase.isNotBlank() -> palette.amber
else -> palette.text
}
}
private fun normalizedAction(raw: String?): String {
val value = raw.orEmpty().uppercase(Locale.US)
@@ -1700,27 +1734,6 @@ class MainActivity : Activity() {
else -> value.ifBlank { "нет данных" }
}
private fun authParts(value: String): Pair<String, String> {
val trimmed = value.trim()
if (trimmed.isBlank() || trimmed.startsWith("Bearer ", ignoreCase = true)) {
return "" to ""
}
val basicPrefix = "Basic "
val raw = if (trimmed.startsWith(basicPrefix, ignoreCase = true)) "" else trimmed
val separator = raw.indexOf(':')
return if (separator >= 0) {
raw.substring(0, separator) to raw.substring(separator + 1)
} else {
raw to ""
}
}
private fun authToken(login: String, password: String): String {
val cleanLogin = login.trim()
val cleanPassword = password.trim()
return if (cleanLogin.isBlank() && cleanPassword.isBlank()) "" else "$cleanLogin:$cleanPassword"
}
private fun modelLabel(value: String): String =
when {
value.contains("gru", ignoreCase = true) -> "PyTorch GRU"
@@ -1,6 +1,5 @@
package xyz.kusoft.tradebotmonitor
import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
@@ -98,7 +97,7 @@ class TradeBotApi(
connection.disconnect()
if (code !in 200..299) {
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw IllegalStateException("HTTP 401: сервер требует логин и пароль")
throw IllegalStateException("HTTP 401: API-токен отсутствует или неверен")
}
throw IllegalStateException("HTTP $code: ${text.take(240)}")
}
@@ -282,15 +281,8 @@ class TradeBotApi(
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
val value = token.trim()
if (value.isBlank()) return
connection.setRequestProperty("X-TradeBot-Token", value)
val authorization = when {
value.startsWith("Basic ", ignoreCase = true) -> value
value.startsWith("Bearer ", ignoreCase = true) -> value
":" in value -> {
val encoded = Base64.encodeToString(value.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
"Basic $encoded"
}
else -> "Bearer $value"
}
val rawToken = value.removePrefix("Bearer ").removePrefix("bearer ").trim()
connection.setRequestProperty("X-TradeBot-Token", rawToken)
val authorization = "Bearer $rawToken"
connection.setRequestProperty("Authorization", authorization)
}