interpret: russian title/detail/advice alongside english findings

This commit is contained in:
Andrew Pimenov 2026-08-09 04:09:01 +03:00
parent 04cf37ef7e
commit db407595d6
2 changed files with 148 additions and 3 deletions

View file

@ -54,6 +54,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
value=0, value=0,
threshold=1, threshold=1,
advice="If every reference host is unreachable the link is down or filtering the probe method.", advice="If every reference host is unreachable the link is down or filtering the probe method.",
title_ru=f"{p.host} не отвечает",
detail_ru=f"{p.sent} проб отправлено через {p.method}, ни одна не вернулась.",
advice_ru="Если все эталонные хосты недоступны, значит канал не работает или фильтрует метод пробы.",
) )
) )
continue continue
@ -69,6 +72,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
value=p.avg_ms, value=p.avg_ms,
threshold=t.latency_ms.warn, threshold=t.latency_ms.warn,
advice="Check for a saturated uplink, a distant route, or a congested Wi-Fi link.", advice="Check for a saturated uplink, a distant route, or a congested Wi-Fi link.",
title_ru=f"Задержка до {p.host} выше целевой",
detail_ru=f"Средняя {p.avg_ms} мс за {p.received} проб через {p.method}.",
advice_ru="Проверьте, не насыщен ли аплинк, не слишком ли далёкий маршрут или не перегружен ли Wi-Fi.",
) )
) )
severity = severity_for(p.jitter_ms, t.jitter_ms) severity = severity_for(p.jitter_ms, t.jitter_ms)
@ -83,11 +89,15 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
value=p.jitter_ms, value=p.jitter_ms,
threshold=t.jitter_ms.warn, threshold=t.jitter_ms.warn,
advice="Unstable latency hurts calls and games more than raw latency does.", advice="Unstable latency hurts calls and games more than raw latency does.",
title_ru=f"Джиттер до {p.host} выше целевого",
detail_ru=f"Джиттер {p.jitter_ms} мс за {p.received} проб через {p.method}.",
advice_ru="Нестабильная задержка вредит звонкам и играм сильнее, чем сама по себе высокая задержка.",
) )
) )
severity = severity_for(p.loss_pct, t.loss_pct) severity = severity_for(p.loss_pct, t.loss_pct)
if severity not in ("ok", "info"): if severity not in ("ok", "info"):
kind = "connection failures" if p.method == "tcp" else "packet loss" kind = "connection failures" if p.method == "tcp" else "packet loss"
kind_ru = "неудачных подключений" if p.method == "tcp" else "потерь пакетов"
findings.append( findings.append(
Finding( Finding(
id=f"latency.loss.{p.label}", id=f"latency.loss.{p.label}",
@ -98,6 +108,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
value=p.loss_pct, value=p.loss_pct,
threshold=t.loss_pct.warn, threshold=t.loss_pct.warn,
advice="Sustained loss on every host points at the local link or the first upstream hop.", advice="Sustained loss on every host points at the local link or the first upstream hop.",
title_ru=f"Потери до {p.host}",
detail_ru=f"{p.loss_pct}% {kind_ru} за {p.sent} проб через {p.method}.",
advice_ru="Устойчивые потери на всех хостах указывают на локальный канал или первый аплинк.",
) )
) )
return findings return findings
@ -112,6 +125,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
title="No path data", title="No path data",
detail=f"The traceroute to {trace.target} returned no hops (backend {trace.backend}).", detail=f"The traceroute to {trace.target} returned no hops (backend {trace.backend}).",
advice="ICMP may be filtered end to end; try --tcp-trace.", advice="ICMP may be filtered end to end; try --tcp-trace.",
title_ru="Нет данных о маршруте",
detail_ru=f"Трассировка до {trace.target} не вернула ни одного хопа (бэкенд {trace.backend}).",
advice_ru="ICMP может быть отфильтрован на всём пути; попробуйте --tcp-trace.",
) )
] ]
findings: list[Finding] = [] findings: list[Finding] = []
@ -130,6 +146,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
value=hop.loss_pct, value=hop.loss_pct,
threshold=20.0, threshold=20.0,
advice="This hop and everything after it share the problem; the hop before it is the last clean one.", advice="This hop and everything after it share the problem; the hop before it is the last clean one.",
title_ru="Устойчивые потери начинаются в середине маршрута",
detail_ru=f"Потери появляются на хопе {hop.ttl} ({hop.ip or 'нет ответа'}) на уровне {hop.loss_pct}% и продолжаются дальше.",
advice_ru="Этот хоп и всё, что после него, делят одну проблему; хоп перед ним — последний чистый.",
) )
) )
break break
@ -141,6 +160,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
title="Path did not reach the target", title="Path did not reach the target",
detail=f"The trace to {trace.target} stopped at hop {trace.hops[-1].ttl}.", detail=f"The trace to {trace.target} stopped at hop {trace.hops[-1].ttl}.",
advice="Many networks drop the final ICMP reply; this alone is not a fault.", advice="Many networks drop the final ICMP reply; this alone is not a fault.",
title_ru="Маршрут не достиг цели",
detail_ru=f"Трассировка до {trace.target} остановилась на хопе {trace.hops[-1].ttl}.",
advice_ru="Многие сети отбрасывают финальный ответ ICMP; само по себе это не является неисправностью.",
) )
) )
return findings return findings
@ -256,6 +278,15 @@ _CONSEQUENCES = {
"F": "A single download makes a call unusable; this is queue bloat, not a slow line.", "F": "A single download makes a call unusable; this is queue bloat, not a slow line.",
"?": "Not measured — the speedtest tier that measures it did not run.", "?": "Not measured — the speedtest tier that measures it did not run.",
} }
_CONSEQUENCES_RU = {
"A": "Видеозвонки и игры остаются плавными, даже когда канал полностью загружен.",
"B": "Почти незаметно; большая отдача может слегка споткнуть видеозвонок.",
"C": "Звонки и игры начинают дёргаться, как только что-то ещё скачивается.",
"D": "Любая крупная передача заставляет звонки заикаться, а страницы — зависать.",
"E": "Соединение кажется сломанным, пока оно занято, хотя пропускная способность в порядке.",
"F": "Одна закачка делает звонок непригодным; это раздутие очереди, а не медленный канал.",
"?": "Не измерено — уровень speedtest-каскада, который это измеряет, не запускался.",
}
_GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"} _GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"}
@ -269,8 +300,9 @@ def grade_bufferbloat(delta_ms: float | None, bands: BufferbloatBands) -> str:
return "F" return "F"
def bufferbloat_consequence(grade: str) -> str: def bufferbloat_consequence(grade: str, lang: str = "en") -> str:
return _CONSEQUENCES.get(grade, _CONSEQUENCES["?"]) table = _CONSEQUENCES_RU if lang == "ru" else _CONSEQUENCES
return table.get(grade, table["?"])
def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]: def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]:
@ -283,10 +315,16 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]
title="No bandwidth measurement", title="No bandwidth measurement",
detail=f"Every speedtest tier failed or was disabled (tried: {tried}).", detail=f"Every speedtest tier failed or was disabled (tried: {tried}).",
advice="Install the Ookla speedtest binary, or rerun without --quick.", advice="Install the Ookla speedtest binary, or rerun without --quick.",
title_ru="Нет замера скорости",
detail_ru=f"Все уровни speedtest-каскада завершились неудачей или были отключены (пробовали: {tried}).",
advice_ru="Установите бинарник Ookla speedtest или перезапустите без --quick.",
) )
] ]
findings: list[Finding] = [] findings: list[Finding] = []
for direction, delta in (("down", speed.bufferbloat_down_ms), ("up", speed.bufferbloat_up_ms)): for direction, direction_ru, delta in (
("down", "приём", speed.bufferbloat_down_ms),
("up", "передача", speed.bufferbloat_up_ms),
):
grade = grade_bufferbloat(delta, bands) grade = grade_bufferbloat(delta, bands)
severity = _GRADE_SEVERITY[grade] severity = _GRADE_SEVERITY[grade]
if severity in ("ok", "info"): if severity in ("ok", "info"):
@ -302,6 +340,10 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]
threshold=bands.c, threshold=bands.c,
advice=bufferbloat_consequence(grade) advice=bufferbloat_consequence(grade)
+ " Enabling SQM/fq_codel on the router is the standard fix.", + " Enabling SQM/fq_codel on the router is the standard fix.",
title_ru=f"Bufferbloat под нагрузкой ({direction_ru}): оценка {grade}",
detail_ru=f"Задержка выросла на {delta} мс при насыщении канала на {direction_ru}.",
advice_ru=bufferbloat_consequence(grade, lang="ru")
+ " Включение SQM/fq_codel на роутере — стандартное решение.",
) )
) )
return findings return findings

View file

@ -391,3 +391,106 @@ def test_overall_score_never_goes_below_zero():
status, score, _ = overall_verdict(findings) status, score, _ = overall_verdict(findings)
assert status == "crit" assert status == "crit"
assert score == 0 assert score == 0
_CYRILLIC = set("абвгдеёжзийклмнопрстуфхцчшщъыьэюя")
def has_cyrillic(text: str) -> bool:
return any(ch in _CYRILLIC for ch in text.lower())
def test_latency_findings_carry_russian_siblings():
findings = latency_findings(
[
ping(received=0, loss_pct=100.0, avg_ms=None, min_ms=None, max_ms=None, jitter_ms=None),
ping(avg_ms=130.0, jitter_ms=25.0, loss_pct=5.0, received=19),
],
thresholds_fixture(),
)
assert findings
for f in findings:
assert f.title_ru and has_cyrillic(f.title_ru)
assert f.detail_ru and has_cyrillic(f.detail_ru)
if f.advice:
assert f.advice_ru and has_cyrillic(f.advice_ru)
def test_path_findings_carry_russian_siblings_for_every_case():
no_hops = TraceResult(target="1.1.1.1", resolved_ip="1.1.1.1", backend="icmplib", hops=[])
findings = path_findings(no_hops)
assert findings[0].title_ru and has_cyrillic(findings[0].title_ru)
assert findings[0].detail_ru and has_cyrillic(findings[0].detail_ru)
assert findings[0].advice_ru and has_cyrillic(findings[0].advice_ru)
loss_jump_trace = TraceResult(
target="1.1.1.1",
resolved_ip="1.1.1.1",
backend="mtr_json",
hops=[
TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0),
TraceHop(ttl=2, ip="10.64.0.1", loss_pct=0.0, avg_ms=9.0),
TraceHop(ttl=3, ip="198.51.100.7", loss_pct=60.0, avg_ms=40.0),
TraceHop(ttl=4, ip="1.1.1.1", loss_pct=55.0, avg_ms=41.0),
],
completed=True,
)
loss_findings = path_findings(loss_jump_trace)
loss = [f for f in loss_findings if f.id == "path.loss_jump"][0]
assert loss.title_ru and has_cyrillic(loss.title_ru)
assert loss.detail_ru and has_cyrillic(loss.detail_ru)
assert loss.advice_ru and has_cyrillic(loss.advice_ru)
incomplete_trace = TraceResult(
target="1.1.1.1",
resolved_ip="1.1.1.1",
backend="mtr_json",
hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)],
completed=False,
)
incomplete = path_findings(incomplete_trace)
incomplete_finding = [f for f in incomplete if f.id == "path.incomplete"][0]
assert incomplete_finding.title_ru and has_cyrillic(incomplete_finding.title_ru)
assert incomplete_finding.detail_ru and has_cyrillic(incomplete_finding.detail_ru)
assert incomplete_finding.advice_ru and has_cyrillic(incomplete_finding.advice_ru)
def thresholds_fixture() -> Thresholds:
return Thresholds()
def test_speed_findings_carry_russian_siblings():
unavailable = speed_findings(SpeedResult(method="none"), BufferbloatBands())
assert unavailable[0].title_ru and has_cyrillic(unavailable[0].title_ru)
assert unavailable[0].detail_ru and has_cyrillic(unavailable[0].detail_ru)
assert unavailable[0].advice_ru and has_cyrillic(unavailable[0].advice_ru)
speed = SpeedResult(
method="cloudflare",
download_mbps=300.0,
upload_mbps=40.0,
bufferbloat_down_ms=150.0,
bufferbloat_up_ms=150.0,
)
findings = speed_findings(speed, BufferbloatBands())
assert len(findings) == 2
for f in findings:
assert f.title_ru and has_cyrillic(f.title_ru)
assert f.detail_ru and has_cyrillic(f.detail_ru)
assert f.advice_ru and has_cyrillic(f.advice_ru)
down = [f for f in findings if f.id == "speed.bufferbloat_down"][0]
up = [f for f in findings if f.id == "speed.bufferbloat_up"][0]
assert "приём" in down.title_ru
assert "передача" in up.title_ru
def test_bufferbloat_consequence_russian_covers_every_grade():
for grade in "ABCDEF?":
text = bufferbloat_consequence(grade, lang="ru")
assert text and has_cyrillic(text)
def test_bufferbloat_consequence_default_english_is_unchanged():
for grade in "ABCDEF?":
assert bufferbloat_consequence(grade) == bufferbloat_consequence(grade, lang="en")
assert "call" in bufferbloat_consequence("F").lower()