626 lines
20 KiB
Python
626 lines
20 KiB
Python
"""Stream-scan InMark-style logs without loading the whole file into RAM."""
|
||||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import gzip
|
|||
|
|
import re
|
|||
|
|
from collections import Counter
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Callable, Iterable
|
|||
|
|
|
|||
|
|
ProgressCb = Callable[[dict[str, Any]], None]
|
|||
|
|
|
|||
|
|
LOG_JOB_HINTS = re.compile(
|
|||
|
|
r"job[_\s]?id|заказ|расхожден|error\s*6[45]|промежутк|частот|"
|
|||
|
|
r"plc_errors|line_u|задани[еюя]|маркиров|отбраков|лог[аиов]?\b|"
|
|||
|
|
r"histogram|ошибк|gap|код[аыов]?\b|смен[аыеу]",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
JOB_ID_RE = re.compile(
|
|||
|
|
r"(?:job(?:[_\s-]?id)?|заказ(?:а|у|ом)?|задани[еюя]|jobid)\s*[:=#№]?\s*(\d{3,8})",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
STANDALONE_JOB_RE = re.compile(r"\b(\d{4,6})\b")
|
|||
|
|
DATE_ISO_RE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b")
|
|||
|
|
DATE_RU_RE = re.compile(r"\b(\d{1,2})[./](\d{1,2})[./](20\d{2}|\d{2})\b")
|
|||
|
|
TS_ISO_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})")
|
|||
|
|
TS_RU_RE = re.compile(r"(\d{2}\.\d{2}\.20\d{2})[ T](\d{2}:\d{2}:\d{2})")
|
|||
|
|
SOURCE_BRACKET_RE = re.compile(r"\[([A-Za-zА-Яа-яЁё0-9_./-]{2,40})\]")
|
|||
|
|
SOURCE_FIELD_RE = re.compile(
|
|||
|
|
r"\b(?:source|src|sender|from|channel|канал)\s*[=:]\s*([^\s,;\]=]+)",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
ERROR_RE = re.compile(r"\b(?:Error|error_?)\s*(\d{1,3})\b")
|
|||
|
|
ERROR64_RE = re.compile(r"\b(?:Error|error_?)\s*64\b", re.IGNORECASE)
|
|||
|
|
ERROR65_RE = re.compile(r"\b(?:Error|error_?)\s*65\b", re.IGNORECASE)
|
|||
|
|
JOB_IN_LINE_RE = re.compile(
|
|||
|
|
r"(?:job(?:[_\s-]?id)?|заказ|JobId)\s*[=:#]?\s*(\d{3,8})",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
CODE_RE = re.compile(
|
|||
|
|
r"\b(?:01\d{14}21[A-Za-z0-9]{1,24}|[A-F0-9]{24,44}|\d{20,32})\b"
|
|||
|
|
)
|
|||
|
|
LOG_NAME_RE = re.compile(r"\.log(\.\d+)?(\.gz)?$", re.IGNORECASE)
|
|||
|
|
|
|||
|
|
PREFERRED_LOG_NAMES = (
|
|||
|
|
"line_u_codes.log",
|
|||
|
|
"plc_errors.log",
|
|||
|
|
"line_u.log",
|
|||
|
|
"line.log",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
GAP_SECONDS = 30
|
|||
|
|
MAX_UNIQUE_CODES = 20_000
|
|||
|
|
MAX_ERROR64_HITS = 80
|
|||
|
|
MAX_GAPS = 25
|
|||
|
|
MAX_SAMPLE_LINES = 12
|
|||
|
|
MAX_PREANALYSIS_CHARS = 70_000
|
|||
|
|
READ_CHUNK = 256 * 1024
|
|||
|
|
|
|||
|
|
|
|||
|
|
def looks_like_log_job(query: str, log_file_names: Iterable[str] | None = None) -> bool:
|
|||
|
|
text = str(query or "")
|
|||
|
|
if LOG_JOB_HINTS.search(text):
|
|||
|
|
return True
|
|||
|
|
names = [str(name or "") for name in (log_file_names or [])]
|
|||
|
|
if names and (JOB_ID_RE.search(text) or DATE_ISO_RE.search(text) or DATE_RU_RE.search(text)):
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_log_filename(name: str) -> bool:
|
|||
|
|
lower = str(name or "").lower()
|
|||
|
|
if LOG_NAME_RE.search(lower):
|
|||
|
|
return True
|
|||
|
|
return ".log." in lower or lower.endswith(".log")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def detect_job_id(query: str) -> str | None:
|
|||
|
|
text = str(query or "")
|
|||
|
|
match = JOB_ID_RE.search(text)
|
|||
|
|
if match:
|
|||
|
|
return match.group(1)
|
|||
|
|
hinted = LOG_JOB_HINTS.search(text)
|
|||
|
|
if hinted:
|
|||
|
|
standalone = STANDALONE_JOB_RE.search(text)
|
|||
|
|
if standalone:
|
|||
|
|
return standalone.group(1)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def detect_shift_date(query: str) -> str | None:
|
|||
|
|
text = str(query or "")
|
|||
|
|
iso = DATE_ISO_RE.search(text)
|
|||
|
|
if iso:
|
|||
|
|
return iso.group(1)
|
|||
|
|
ru = DATE_RU_RE.search(text)
|
|||
|
|
if not ru:
|
|||
|
|
return None
|
|||
|
|
day, month, year = ru.group(1), ru.group(2), ru.group(3)
|
|||
|
|
if len(year) == 2:
|
|||
|
|
year = f"20{year}"
|
|||
|
|
try:
|
|||
|
|
return datetime(int(year), int(month), int(day)).strftime("%Y-%m-%d")
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def detect_log_encoding(path: Path) -> str:
|
|||
|
|
try:
|
|||
|
|
raw = path.open("rb").read(65536)
|
|||
|
|
except OSError:
|
|||
|
|
return "utf-8"
|
|||
|
|
if raw.startswith(b"\xef\xbb\xbf"):
|
|||
|
|
return "utf-8-sig"
|
|||
|
|
for encoding in ("utf-8", "cp1251"):
|
|||
|
|
try:
|
|||
|
|
raw.decode(encoding)
|
|||
|
|
return encoding
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
continue
|
|||
|
|
return "cp1251"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_timestamp(line: str) -> datetime | None:
|
|||
|
|
match = TS_ISO_RE.search(line)
|
|||
|
|
if match:
|
|||
|
|
try:
|
|||
|
|
return datetime.strptime(
|
|||
|
|
f"{match.group(1)} {match.group(2)}", "%Y-%m-%d %H:%M:%S"
|
|||
|
|
)
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
match = TS_RU_RE.search(line)
|
|||
|
|
if not match:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
return datetime.strptime(
|
|||
|
|
f"{match.group(1)} {match.group(2)}", "%d.%m.%Y %H:%M:%S"
|
|||
|
|
)
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _source_of(line: str, fallback: str) -> str:
|
|||
|
|
field = SOURCE_FIELD_RE.search(line)
|
|||
|
|
if field:
|
|||
|
|
return field.group(1)[:40]
|
|||
|
|
bracket = SOURCE_BRACKET_RE.search(line)
|
|||
|
|
if bracket:
|
|||
|
|
token = bracket.group(1)
|
|||
|
|
if not re.fullmatch(r"\d{1,2}:\d{2}:\d{2}.*", token):
|
|||
|
|
return token[:40]
|
|||
|
|
return fallback
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _open_log_binary(path: Path):
|
|||
|
|
name = path.name.lower()
|
|||
|
|
handle = path.open("rb")
|
|||
|
|
if name.endswith(".gz"):
|
|||
|
|
return gzip.GzipFile(fileobj=handle, mode="rb"), handle
|
|||
|
|
return handle, handle
|
|||
|
|
|
|||
|
|
|
|||
|
|
def iter_log_lines(path: Path, encoding: str):
|
|||
|
|
"""Yield (line_no, text, bytes_read, total_bytes) without loading the file."""
|
|||
|
|
size = 0
|
|||
|
|
try:
|
|||
|
|
size = path.stat().st_size
|
|||
|
|
except OSError:
|
|||
|
|
size = 0
|
|||
|
|
stream, raw = _open_log_binary(path)
|
|||
|
|
try:
|
|||
|
|
leftover = b""
|
|||
|
|
line_no = 0
|
|||
|
|
bytes_read = 0
|
|||
|
|
while True:
|
|||
|
|
chunk = stream.read(READ_CHUNK)
|
|||
|
|
if not chunk:
|
|||
|
|
break
|
|||
|
|
bytes_read += len(chunk)
|
|||
|
|
data = leftover + chunk
|
|||
|
|
parts = data.split(b"\n")
|
|||
|
|
leftover = parts.pop()
|
|||
|
|
for part in parts:
|
|||
|
|
line_no += 1
|
|||
|
|
text = part.rstrip(b"\r").decode(encoding, errors="replace")
|
|||
|
|
yield line_no, text, min(bytes_read, size) if size else bytes_read, size
|
|||
|
|
if leftover:
|
|||
|
|
line_no += 1
|
|||
|
|
text = leftover.rstrip(b"\r").decode(encoding, errors="replace")
|
|||
|
|
yield line_no, text, size or bytes_read, size
|
|||
|
|
finally:
|
|||
|
|
try:
|
|||
|
|
stream.close()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
if raw is not stream:
|
|||
|
|
try:
|
|||
|
|
raw.close()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def rank_log_files(entries: list[dict]) -> list[dict]:
|
|||
|
|
def score(entry: dict) -> tuple:
|
|||
|
|
name = str(entry.get("name") or "").lower()
|
|||
|
|
preferred = 0
|
|||
|
|
for index, prefix in enumerate(PREFERRED_LOG_NAMES):
|
|||
|
|
if name.startswith(prefix) or prefix in name:
|
|||
|
|
preferred = 10 - index
|
|||
|
|
break
|
|||
|
|
size = int(entry.get("size") or 0)
|
|||
|
|
return (preferred, 1 if is_log_filename(name) else 0, min(size, 10**12))
|
|||
|
|
|
|||
|
|
logs = [entry for entry in entries if is_log_filename(str(entry.get("name") or ""))]
|
|||
|
|
logs.sort(key=score, reverse=True)
|
|||
|
|
return logs[:6]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit_progress(
|
|||
|
|
on_progress: ProgressCb | None,
|
|||
|
|
*,
|
|||
|
|
phase: str,
|
|||
|
|
message: str,
|
|||
|
|
percent: int | None,
|
|||
|
|
file: str = "",
|
|||
|
|
) -> None:
|
|||
|
|
if not on_progress:
|
|||
|
|
return
|
|||
|
|
payload: dict[str, Any] = {
|
|||
|
|
"phase": phase,
|
|||
|
|
"message": message,
|
|||
|
|
"percent": percent,
|
|||
|
|
}
|
|||
|
|
if file:
|
|||
|
|
payload["file"] = file
|
|||
|
|
on_progress(payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _scan_one_file(
|
|||
|
|
path: Path,
|
|||
|
|
display_name: str,
|
|||
|
|
job_id: str | None,
|
|||
|
|
shift_date: str | None,
|
|||
|
|
*,
|
|||
|
|
on_progress: ProgressCb | None = None,
|
|||
|
|
overall_offset: int = 0,
|
|||
|
|
overall_total: int = 1,
|
|||
|
|
cancel: Callable[[], bool] | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
encoding = detect_log_encoding(path)
|
|||
|
|
sources: Counter[str] = Counter()
|
|||
|
|
errors: Counter[str] = Counter()
|
|||
|
|
hours: Counter[str] = Counter()
|
|||
|
|
unique_codes: set[str] = set()
|
|||
|
|
unique_capped = False
|
|||
|
|
code_events = 0
|
|||
|
|
error64: list[dict[str, Any]] = []
|
|||
|
|
error65: list[dict[str, Any]] = []
|
|||
|
|
gaps: list[dict[str, Any]] = []
|
|||
|
|
samples: list[dict[str, Any]] = []
|
|||
|
|
first_ts: datetime | None = None
|
|||
|
|
last_ts: datetime | None = None
|
|||
|
|
prev_ts: datetime | None = None
|
|||
|
|
prev_line = 0
|
|||
|
|
line_count = 0
|
|||
|
|
job_lines = 0
|
|||
|
|
shift_lines = 0
|
|||
|
|
last_pct = -1
|
|||
|
|
|
|||
|
|
def maybe_sample(line_no: int, line: str, reason: str) -> None:
|
|||
|
|
if len(samples) >= MAX_SAMPLE_LINES:
|
|||
|
|
return
|
|||
|
|
samples.append(
|
|||
|
|
{
|
|||
|
|
"file": display_name,
|
|||
|
|
"line": line_no,
|
|||
|
|
"text": line[:240],
|
|||
|
|
"reason": reason,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for line_no, line, bytes_read, total in iter_log_lines(path, encoding):
|
|||
|
|
if cancel and cancel():
|
|||
|
|
break
|
|||
|
|
line_count = line_no
|
|||
|
|
if overall_total:
|
|||
|
|
local = (bytes_read / total) if total else 1.0
|
|||
|
|
overall = overall_offset + local * (total or 0)
|
|||
|
|
pct = min(99, int(100 * overall / overall_total))
|
|||
|
|
if pct >= last_pct + 2:
|
|||
|
|
last_pct = pct
|
|||
|
|
_emit_progress(
|
|||
|
|
on_progress,
|
|||
|
|
phase="scan",
|
|||
|
|
message=f"Читаю лог… {pct}%",
|
|||
|
|
percent=pct,
|
|||
|
|
file=display_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
ts = _parse_timestamp(line)
|
|||
|
|
if ts:
|
|||
|
|
if first_ts is None:
|
|||
|
|
first_ts = ts
|
|||
|
|
maybe_sample(line_no, line, "first")
|
|||
|
|
last_ts = ts
|
|||
|
|
hours[ts.strftime("%Y-%m-%d %H:00")] += 1
|
|||
|
|
if prev_ts is not None:
|
|||
|
|
delta = (ts - prev_ts).total_seconds()
|
|||
|
|
if delta >= GAP_SECONDS:
|
|||
|
|
gaps.append(
|
|||
|
|
{
|
|||
|
|
"file": display_name,
|
|||
|
|
"from_line": prev_line,
|
|||
|
|
"to_line": line_no,
|
|||
|
|
"from_ts": prev_ts.isoformat(sep=" ", timespec="seconds"),
|
|||
|
|
"to_ts": ts.isoformat(sep=" ", timespec="seconds"),
|
|||
|
|
"seconds": int(delta),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
prev_ts = ts
|
|||
|
|
prev_line = line_no
|
|||
|
|
|
|||
|
|
in_shift = True
|
|||
|
|
if shift_date and ts:
|
|||
|
|
in_shift = ts.strftime("%Y-%m-%d") == shift_date
|
|||
|
|
elif shift_date and shift_date in line:
|
|||
|
|
in_shift = True
|
|||
|
|
elif shift_date:
|
|||
|
|
in_shift = False
|
|||
|
|
if in_shift:
|
|||
|
|
shift_lines += 1
|
|||
|
|
|
|||
|
|
job_hit = JOB_IN_LINE_RE.search(line)
|
|||
|
|
if job_id and (job_id in line or (job_hit and job_hit.group(1) == job_id)):
|
|||
|
|
job_lines += 1
|
|||
|
|
elif job_hit:
|
|||
|
|
job_lines += 0
|
|||
|
|
|
|||
|
|
sources[_source_of(line, display_name)] += 1
|
|||
|
|
|
|||
|
|
for err in ERROR_RE.findall(line):
|
|||
|
|
if in_shift or not shift_date:
|
|||
|
|
errors[err] += 1
|
|||
|
|
|
|||
|
|
if ERROR64_RE.search(line) and len(error64) < MAX_ERROR64_HITS:
|
|||
|
|
error64.append(
|
|||
|
|
{
|
|||
|
|
"file": display_name,
|
|||
|
|
"line": line_no,
|
|||
|
|
"ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "",
|
|||
|
|
"text": line[:220],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
maybe_sample(line_no, line, "Error64")
|
|||
|
|
if ERROR65_RE.search(line) and len(error65) < MAX_ERROR64_HITS:
|
|||
|
|
error65.append(
|
|||
|
|
{
|
|||
|
|
"file": display_name,
|
|||
|
|
"line": line_no,
|
|||
|
|
"ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "",
|
|||
|
|
"text": line[:220],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
maybe_sample(line_no, line, "Error65")
|
|||
|
|
|
|||
|
|
for code in CODE_RE.findall(line):
|
|||
|
|
code_events += 1
|
|||
|
|
if not unique_capped:
|
|||
|
|
unique_codes.add(code)
|
|||
|
|
if len(unique_codes) >= MAX_UNIQUE_CODES:
|
|||
|
|
unique_capped = True
|
|||
|
|
|
|||
|
|
if last_ts and line_count:
|
|||
|
|
maybe_sample(line_count, "", "last")
|
|||
|
|
if samples and samples[-1]["reason"] == "last" and not samples[-1]["text"]:
|
|||
|
|
samples.pop()
|
|||
|
|
|
|||
|
|
gaps.sort(key=lambda item: int(item.get("seconds") or 0), reverse=True)
|
|||
|
|
gaps = gaps[:MAX_GAPS]
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"file": display_name,
|
|||
|
|
"path": str(path),
|
|||
|
|
"encoding": encoding,
|
|||
|
|
"lines": line_count,
|
|||
|
|
"job_lines": job_lines,
|
|||
|
|
"shift_lines": shift_lines,
|
|||
|
|
"first_ts": first_ts.isoformat(sep=" ", timespec="seconds") if first_ts else "",
|
|||
|
|
"last_ts": last_ts.isoformat(sep=" ", timespec="seconds") if last_ts else "",
|
|||
|
|
"sources": sources.most_common(30),
|
|||
|
|
"errors": errors.most_common(25),
|
|||
|
|
"hours": hours.most_common(24),
|
|||
|
|
"unique_codes": len(unique_codes),
|
|||
|
|
"unique_capped": unique_capped,
|
|||
|
|
"code_events": code_events,
|
|||
|
|
"error64": error64,
|
|||
|
|
"error65": error65,
|
|||
|
|
"gaps": gaps,
|
|||
|
|
"samples": samples,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _format_duration(seconds: int) -> str:
|
|||
|
|
value = max(0, int(seconds))
|
|||
|
|
if value < 60:
|
|||
|
|
return f"{value} с"
|
|||
|
|
minutes, rest = divmod(value, 60)
|
|||
|
|
if minutes < 60:
|
|||
|
|
return f"{minutes} мин {rest} с" if rest else f"{minutes} мин"
|
|||
|
|
hours, minutes = divmod(minutes, 60)
|
|||
|
|
return f"{hours} ч {minutes} мин"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_preanalysis_markdown(report: dict[str, Any]) -> str:
|
|||
|
|
job_id = report.get("job_id") or "не найден в вопросе"
|
|||
|
|
shift = report.get("shift_date") or "не указана"
|
|||
|
|
lines: list[str] = [
|
|||
|
|
"# PREANALYSIS",
|
|||
|
|
"",
|
|||
|
|
"Сервер уже просканировал логи **одним проходом**. "
|
|||
|
|
"Используй этот файл первым. Не читай логи целиком заново, "
|
|||
|
|
"если цифры выглядят правдоподобно. Для проверки бери фрагмент "
|
|||
|
|
"по номеру строки (`:15231:line_u_codes.log.1` или "
|
|||
|
|
"`::log:line_u_codes.log.1:15231::`).",
|
|||
|
|
"",
|
|||
|
|
f"- Job id: `{job_id}`",
|
|||
|
|
f"- Дата смены (из вопроса): `{shift}`",
|
|||
|
|
f"- Файлов просканировано: {len(report.get('files') or [])}",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
for item in report.get("files") or []:
|
|||
|
|
name = item.get("file") or "log"
|
|||
|
|
lines.extend(
|
|||
|
|
[
|
|||
|
|
f"## {name}",
|
|||
|
|
"",
|
|||
|
|
f"- Кодировка: `{item.get('encoding')}`",
|
|||
|
|
f"- Строк: {item.get('lines')}",
|
|||
|
|
f"- Строк с job id: {item.get('job_lines')}",
|
|||
|
|
f"- Строк за смену: {item.get('shift_lines')}",
|
|||
|
|
f"- Интервал: {item.get('first_ts') or '—'} → {item.get('last_ts') or '—'}",
|
|||
|
|
f"- Событий с кодами: {item.get('code_events')}",
|
|||
|
|
f"- Уникальных кодов: {item.get('unique_codes')}"
|
|||
|
|
+ (" (учёт ограничен) " if item.get("unique_capped") else ""),
|
|||
|
|
"",
|
|||
|
|
"### Источники",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
sources = item.get("sources") or []
|
|||
|
|
if sources:
|
|||
|
|
for src, count in sources:
|
|||
|
|
lines.append(f"- `{src}`: {count}")
|
|||
|
|
else:
|
|||
|
|
lines.append("- (нет)")
|
|||
|
|
lines.extend(["", "### Ошибки за смену (гистограмма)", ""])
|
|||
|
|
errors = item.get("errors") or []
|
|||
|
|
if errors:
|
|||
|
|
for code, count in errors:
|
|||
|
|
lines.append(f"- Error {code}: {count}")
|
|||
|
|
else:
|
|||
|
|
lines.append("- (нет совпадений Error N)")
|
|||
|
|
lines.extend(["", "### Частота по часам (топ)", ""])
|
|||
|
|
hours = item.get("hours") or []
|
|||
|
|
if hours:
|
|||
|
|
for hour, count in hours[:16]:
|
|||
|
|
lines.append(f"- {hour}: {count}")
|
|||
|
|
else:
|
|||
|
|
lines.append("- (нет меток времени)")
|
|||
|
|
lines.extend(["", "### Длинные промежутки (≥ 30 с)", ""])
|
|||
|
|
gaps = item.get("gaps") or []
|
|||
|
|
if gaps:
|
|||
|
|
for gap in gaps:
|
|||
|
|
lines.append(
|
|||
|
|
f"- {_format_duration(int(gap.get('seconds') or 0))}: "
|
|||
|
|
f"{gap.get('from_ts')} → {gap.get('to_ts')} "
|
|||
|
|
f"(`:{gap.get('from_line')}:{name}` … `:{gap.get('to_line')}:{name}`)"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
lines.append("- нет промежутков ≥ 30 с")
|
|||
|
|
lines.extend(["", "### Error64", ""])
|
|||
|
|
hits64 = item.get("error64") or []
|
|||
|
|
if hits64:
|
|||
|
|
for hit in hits64[:40]:
|
|||
|
|
ts = hit.get("ts") or "—"
|
|||
|
|
lines.append(
|
|||
|
|
f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
lines.append("- нет")
|
|||
|
|
lines.extend(["", "### Error65", ""])
|
|||
|
|
hits65 = item.get("error65") or []
|
|||
|
|
if hits65:
|
|||
|
|
for hit in hits65[:40]:
|
|||
|
|
ts = hit.get("ts") or "—"
|
|||
|
|
lines.append(
|
|||
|
|
f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
lines.append("- нет")
|
|||
|
|
lines.extend(["", "### Примеры строк", ""])
|
|||
|
|
for sample in item.get("samples") or []:
|
|||
|
|
text = str(sample.get("text") or "").strip()
|
|||
|
|
if not text:
|
|||
|
|
continue
|
|||
|
|
lines.append(
|
|||
|
|
f"- `:{sample.get('line')}:{name}` ({sample.get('reason')}): `{text}`"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
|
|||
|
|
text = "\n".join(lines).strip() + "\n"
|
|||
|
|
if len(text) > MAX_PREANALYSIS_CHARS:
|
|||
|
|
text = text[:MAX_PREANALYSIS_CHARS].rstrip() + "\n\n…\n"
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def preanalyze_logs(
|
|||
|
|
files: list[tuple[Path, str]],
|
|||
|
|
query: str,
|
|||
|
|
*,
|
|||
|
|
on_progress: ProgressCb | None = None,
|
|||
|
|
cancel: Callable[[], bool] | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
job_id = detect_job_id(query)
|
|||
|
|
shift_date = detect_shift_date(query)
|
|||
|
|
existing = [(path, name) for path, name in files if path.exists() and path.is_file()]
|
|||
|
|
total_bytes = 0
|
|||
|
|
for path, _name in existing:
|
|||
|
|
try:
|
|||
|
|
total_bytes += path.stat().st_size
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
total_bytes = max(total_bytes, 1)
|
|||
|
|
offset = 0
|
|||
|
|
scanned: list[dict[str, Any]] = []
|
|||
|
|
_emit_progress(
|
|||
|
|
on_progress,
|
|||
|
|
phase="scan",
|
|||
|
|
message="Читаю лог… 0%",
|
|||
|
|
percent=0,
|
|||
|
|
)
|
|||
|
|
for path, name in existing:
|
|||
|
|
size = 0
|
|||
|
|
try:
|
|||
|
|
size = path.stat().st_size
|
|||
|
|
except OSError:
|
|||
|
|
size = 0
|
|||
|
|
item = _scan_one_file(
|
|||
|
|
path,
|
|||
|
|
name,
|
|||
|
|
job_id,
|
|||
|
|
shift_date,
|
|||
|
|
on_progress=on_progress,
|
|||
|
|
overall_offset=offset,
|
|||
|
|
overall_total=total_bytes,
|
|||
|
|
cancel=cancel,
|
|||
|
|
)
|
|||
|
|
scanned.append(item)
|
|||
|
|
offset += size
|
|||
|
|
if cancel and cancel():
|
|||
|
|
break
|
|||
|
|
_emit_progress(
|
|||
|
|
on_progress,
|
|||
|
|
phase="scan",
|
|||
|
|
message="Читаю лог… 100%",
|
|||
|
|
percent=100,
|
|||
|
|
)
|
|||
|
|
report = {
|
|||
|
|
"job_id": job_id or "",
|
|||
|
|
"shift_date": shift_date or "",
|
|||
|
|
"files": scanned,
|
|||
|
|
}
|
|||
|
|
report["markdown"] = render_preanalysis_markdown(report)
|
|||
|
|
report["summary"] = _short_summary(report)
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _short_summary(report: dict[str, Any]) -> str:
|
|||
|
|
bits = []
|
|||
|
|
if report.get("job_id"):
|
|||
|
|
bits.append(f"job {report['job_id']}")
|
|||
|
|
if report.get("shift_date"):
|
|||
|
|
bits.append(report["shift_date"])
|
|||
|
|
for item in report.get("files") or []:
|
|||
|
|
bits.append(
|
|||
|
|
f"{item.get('file')}: {item.get('lines')} строк, "
|
|||
|
|
f"Error64×{len(item.get('error64') or [])}, "
|
|||
|
|
f"промежутков {len(item.get('gaps') or [])}"
|
|||
|
|
)
|
|||
|
|
return "; ".join(bits) if bits else "логи просканированы"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def excerpt_lines(
|
|||
|
|
path: Path,
|
|||
|
|
start: int,
|
|||
|
|
end: int,
|
|||
|
|
encoding: str | None = None,
|
|||
|
|
) -> tuple[str, str]:
|
|||
|
|
"""Return (text, encoding) for 1-indexed inclusive line range."""
|
|||
|
|
start = max(1, int(start))
|
|||
|
|
end = max(start, int(end))
|
|||
|
|
encoding = encoding or detect_log_encoding(path)
|
|||
|
|
collected: list[str] = []
|
|||
|
|
for line_no, text, _read, _total in iter_log_lines(path, encoding):
|
|||
|
|
if line_no < start:
|
|||
|
|
continue
|
|||
|
|
if line_no > end:
|
|||
|
|
break
|
|||
|
|
collected.append(f"{line_no}\t{text}")
|
|||
|
|
return "\n".join(collected) + ("\n" if collected else ""), encoding
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_preanalysis_md(workspace: Path, markdown: str) -> Path:
|
|||
|
|
target = Path(workspace) / "PREANALYSIS.md"
|
|||
|
|
target.write_text(markdown, encoding="utf-8")
|
|||
|
|
listing = Path(workspace) / "PROJECT_FILES.md"
|
|||
|
|
if listing.exists():
|
|||
|
|
extra = "\n- ./PREANALYSIS.md — server-side log scan; read this first\n"
|
|||
|
|
text = listing.read_text(encoding="utf-8")
|
|||
|
|
if "PREANALYSIS.md" not in text:
|
|||
|
|
listing.write_text(text.rstrip() + extra, encoding="utf-8")
|
|||
|
|
return target
|