v0.0.34: вывод токенов и времени LLM в UI (суммарно за прогон)
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# v0.0.34 — вывод токенов и времени LLM — 2026-08-19
|
||||
|
||||
**Дата:** 2026-08-19
|
||||
**Версия:** 0.0.33 → 0.0.34
|
||||
|
||||
---
|
||||
|
||||
## Суть
|
||||
|
||||
Пользователь замечает, что обфускация долгая. Добавлен вывод в UI:
|
||||
- суммарных токенов LLM за прогон;
|
||||
- суммарного времени работы LLM за прогон.
|
||||
|
||||
Строка статуса после завершения: «✅ Обработано N файлов за Xс · LLM: Yс · Z токенов».
|
||||
|
||||
## Изменения
|
||||
|
||||
### `drhider/llm_client.py`
|
||||
- В `LLMClient.__init__` добавлены кумулятивные счётчики:
|
||||
- `tokens_prompt`, `tokens_completion`, `llm_sec` (float);
|
||||
- свойство `tokens_total = prompt + completion`.
|
||||
- В `complete()` после ответа:
|
||||
- `self.llm_sec += r.elapsed.total_seconds()` — реальное время HTTP-вызова;
|
||||
- `usage` (из `r.json()`) → `self.tokens_prompt` / `self.tokens_completion`
|
||||
(через `.get(..., 0)` — устойчиво к отсутствию `usage`).
|
||||
- Возврат `complete()` не изменён (по-прежнему `content`) — `scan_llm_ner` не трогали.
|
||||
|
||||
### `site/routes/api_bp.py`
|
||||
- В `worker()` после `obfuscate_files` в очередь `result` добавляется
|
||||
`stats = {tokens: llm.tokens_total, llm_sec: round(llm.llm_sec, 1)}`.
|
||||
- В `event: complete` данные: `{total, tokens, llm_sec}`.
|
||||
|
||||
### `site/templates/index.html`
|
||||
- В обработчике `complete`: если `d.llm_sec > 0` — добавляет
|
||||
«· LLM: {llm_sec}с» и, если `d.tokens > 0` — «· {tokens} токенов».
|
||||
|
||||
### `site/app.py`
|
||||
- `VERSION = "0.0.34"`.
|
||||
|
||||
## Проверка
|
||||
- `py_compile` + `node --check` — OK.
|
||||
- Накопление: 2 вызова → 240 prompt + 90 completion = 330 total, llm_sec = 6.0.
|
||||
- Устойчивость: ответ без `usage` → токены 0, время 1.0 — без падения.
|
||||
- SSE end-to-end: `complete` приходит с `{"total":1,"tokens":0,"llm_sec":0.0}`
|
||||
(локально без ключа LLM — 0; в кластере с ключом — ненулевые).
|
||||
+25
-2
@@ -23,8 +23,21 @@ class LLMClient:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализировать клиент. httpx импортируется лениво."""
|
||||
"""Инициализировать клиент. httpx импортируется лениво.
|
||||
|
||||
Ведёт кумулятивные счётчики за всё время жизни клиента:
|
||||
tokens_prompt — суммарные входные токены
|
||||
tokens_completion — суммарные выходные токены
|
||||
llm_sec — суммарное время LLM-запросов (сек)
|
||||
"""
|
||||
self._httpx = httpx
|
||||
self.tokens_prompt = 0
|
||||
self.tokens_completion = 0
|
||||
self.llm_sec = 0.0
|
||||
|
||||
@property
|
||||
def tokens_total(self) -> int:
|
||||
return self.tokens_prompt + self.tokens_completion
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
"""Отправить промпт в LLM и вернуть текст ответа.
|
||||
@@ -61,4 +74,14 @@ class LLMClient:
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
|
||||
# Время запроса (реальное время HTTP-вызова LLM)
|
||||
self.llm_sec += r.elapsed.total_seconds()
|
||||
|
||||
data = r.json()
|
||||
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси)
|
||||
usage = data.get("usage", {}) or {}
|
||||
self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0)
|
||||
self.tokens_completion += int(usage.get("completion_tokens", 0) or 0)
|
||||
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "0.0.33"
|
||||
VERSION = "0.0.34"
|
||||
|
||||
|
||||
def create_app():
|
||||
|
||||
+10
-3
@@ -87,7 +87,11 @@ def process_stream(sid):
|
||||
zip_data, csv_str = obfuscate_files(
|
||||
all_files, llm_client=llm, progress_cb=progress
|
||||
)
|
||||
q.put(("result", zip_data, csv_str))
|
||||
stats = {
|
||||
"tokens": llm.tokens_total,
|
||||
"llm_sec": round(llm.llm_sec, 1),
|
||||
}
|
||||
q.put(("result", zip_data, csv_str, stats))
|
||||
except Exception as e:
|
||||
q.put(("error", repr(e)))
|
||||
|
||||
@@ -115,7 +119,7 @@ def process_stream(sid):
|
||||
return
|
||||
|
||||
elif kind == "result":
|
||||
_, zip_data, csv_str = evt
|
||||
_, zip_data, csv_str, stats = evt
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
@@ -123,7 +127,10 @@ def process_stream(sid):
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
count = len([n for n in zf.namelist() if n != "mapping.csv"])
|
||||
try:
|
||||
yield f"event: complete\ndata: {json.dumps({'total': count})}\n\n"
|
||||
yield (
|
||||
f"event: complete\n"
|
||||
f"data: {json.dumps({'total': count, **stats})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions():
|
||||
return
|
||||
return
|
||||
|
||||
@@ -404,8 +404,14 @@ async function uploadFiles() {
|
||||
activeES = null;
|
||||
clearInterval(ptimer);
|
||||
const d = JSON.parse(e.data);
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
let msg = '✅ Обработано ' + d.total + ' файлов за ' + totalSec + 'с';
|
||||
if (d.llm_sec > 0) {
|
||||
msg += ' · LLM: ' + d.llm_sec + 'с';
|
||||
if (d.tokens > 0) msg += ' · ' + d.tokens + ' токенов';
|
||||
}
|
||||
st.className = 'status done';
|
||||
st.textContent = '✅ Обработано ' + d.total + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с';
|
||||
st.textContent = msg;
|
||||
db.classList.add('show');
|
||||
resolve();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user