0.4 - Прогон по одной записи, стрим в файл.

This commit is contained in:
2026-03-12 17:16:18 +03:00
parent f2eefee077
commit d637e6aae8
9 changed files with 223 additions and 122 deletions
+32 -15
View File
@@ -1,27 +1,44 @@
# Папки
# cart-to-json
Парсинг .txt списков автомобилей в JSON через Ollama.
## Папки
- `lists/` — необработанные .txt файлы
- `done/` — обработанные файлы (переносятся из lists/)
- `done/` — обработанные (переносятся из lists/)
- `res/` — результат в JSON
# Запуск
## Формат входных данных
Табуляция: `brand`, `model`, `engine_code`, `date_range`.
Разделители записей задаются в `RECORD_DELIMITERS` (по умолчанию `\n`, `\r\n`, `\r`).
**Важно:** `\n` разделяет только по строкам. Несколько записей в одной строке не разобьются.
## Docker
```bash
chmod +x run.sh && ./run.sh
```
## Запуск
```bash
# Статус загрузки и работы модели в отдельном терминале
docker compose logs -f ollama
```
При первом запуске Ollama скачает модель `gemma3n:e4b` (7.5 GB) (может занять несколько минут).
## Локально
### Локально
```bash
pip install -r requirements.txt
python3 parse.py
```
### Docker
```bash
chmod +x run.sh && ./run.sh
```
Логи Ollama:
```bash
docker compose logs -f ollama
```
При первом запуске скачивается модель `mithunrajm06/gemma-3n-e4b:latest`.
## Переменные окружения
| Переменная | Описание |
|------------|----------|
| `OLLAMA_HOST` | URL Ollama (по умолчанию `http://localhost:11434`) |
| `MODEL` | Модель для парсинга |
| `NUM_CTX` | Размер контекста (32768) |
| `NUM_PREDICT` | Лимит выходных токенов (512) |
| `LOG_STREAM` | `1` — вывод стрима в консоль |
| `SHOW_IO` | `1` — показывать вход и выход по каждой записи |
+3 -3
View File
@@ -7,14 +7,14 @@ services:
ports:
- "11435:11434" # 11435 — если локальный Ollama уже на 11434
environment:
OLLAMA_MODEL: gemma3n:e4b
OLLAMA_MODEL: mithunrajm06/gemma-3n-e4b:latest
entrypoint: ["/bin/sh", "-c"]
command:
- |
ollama serve &
sleep 10
for i in 1 2 3 4 5 6 7 8 9 10; do
ollama pull $${OLLAMA_MODEL:-gemma3n:e4b} 2>/dev/null && break
ollama pull $${OLLAMA_MODEL:-mithunrajm06/gemma-3n-e4b:latest} 2>/dev/null && break
sleep 5
done
wait
@@ -28,7 +28,7 @@ services:
- ./done:/app/done
environment:
OLLAMA_HOST: http://ollama:11434
MODEL: gemma3n:e4b
MODEL: mithunrajm06/gemma-3n-e4b:latest
depends_on:
ollama:
condition: service_started
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}"
MODEL="${MODEL:-gemma3n:e4b}"
MODEL="${MODEL:-mithunrajm06/gemma-3n-e4b:latest}"
MODEL_NAME="${MODEL%%:*}"
echo "Waiting for Ollama..."
+11 -2
View File
@@ -1,7 +1,16 @@
HYUNDAI Accent 1.3 [G4E-A] 08.1999- HYUNDAI Accent 1.3 [G4EH] 08.2002- \n HYUNDAI Accent 1.4 GL 11.2005-
HYUNDAI Accent 1.5 [G4EB] 08.2002- HYUNDAI Accent 1.6 [G4ED-G] 12.2002-
HYUNDAI Accent 1.3 [G4E-A] 08.1999-
HYUNDAI Accent 1.3 [G4EH] 08.2002-
HYUNDAI Accent 1.4 GL 11.2005-
HYUNDAI Accent 1.5 [G4EB] 08.2002-
HYUNDAI Accent 1.6 [G4ED-G] 12.2002-
HYUNDAI Accent 1.6 GLS 11.2005-
HYUNDAI Coupe 1.6 16V [G4ED-G] 03.2002-
HYUNDAI Coupe 2.0 16V [G4GF] 08.2002-
HYUNDAI Coupe 2.0 GLS 08.2002-
KIA Rio 1.6 16V [G4ED] 03.2005-
MERCEDES-BENZ CABRIOLET (W111) 280 SE 3.5 (111.025) [M 116.980] 01/1969 - 12/1971
MERCEDES-BENZ COUPE (W111) 280 SE 3.5 (111.026) [M 116.980] 01/1969 - 12/1971
MERCEDES-BENZ S-CLASS (W108, W109) 280 SE,SEL 3.5 (108.057, 108.058) [M 116.980] 03/1971 - 08/1972
MERCEDES-BENZ S-CLASS (W108, W109) 300 SEL 3.5 (109.056) [M 116.981] 01/1970 - 07/1972
MERCEDES-BENZ S-CLASS (W116) 350 SE,SEL (116.028, 116.029) [M 116.983] 08/1972 - 02/1976
MERCEDES-BENZ SL купе (C107) 350 SLC (107.023) [M 116.982, M 116.984] 01/1972 - 02/1976
+74 -55
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python3
"""
Скрипт загружает .txt файлы из lists/, отправляет каждый в локальную Ollama
с системным промптом и сохраняет ответ в res/. Успешные файлы переносятся в done/.
При падении можно перезапустить — необработанные остаются в lists/.
Скрипт загружает .txt файлы из lists/, разбивает по разделителю,
отправляет каждую запись в Ollama, собирает JSON в res/.
"""
import json
@@ -19,71 +18,97 @@ LISTS_DIR = pathlib.Path(__file__).parent / "lists"
RES_DIR = pathlib.Path(__file__).parent / "res"
DONE_DIR = pathlib.Path(__file__).parent / "done"
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
MODEL = os.environ.get("MODEL", "gemma3n:e4b")
# размер контекста (токенов для этой модели это максимум, больше контекст больше расход ОЗУ)
MODEL = os.environ.get("MODEL", "mithunrajm06/gemma-3n-e4b:latest")
NUM_CTX = int(os.environ.get("NUM_CTX", "32768"))
NUM_PREDICT = int(os.environ.get("NUM_PREDICT", "512"))
RETRY_COUNT = 3
RETRY_DELAY = 5
LOG_STREAM = os.environ.get("LOG_STREAM", "").lower() in ("1", "true", "yes")
# показывать вход и выход по каждой записи (0/false — отключить)
SHOW_IO = os.environ.get("SHOW_IO", "1").lower() in ("1", "true", "yes")
SYSTEM_PROMPT = """You are a data processing assistant.
The input text is a list of tab-separated lines: brand, model, engine_code, date_range.
Convert to a JSON array of objects with fields: brand, model, engine_code, date_range.
The number of elements in JSON MUST match the number of lines in the list.
Keep empty fields as empty string "".
Output ONLY the JSON array. No explanations, no markdown, no other text before or after."""
# Разделители записей при разбиении файла (\n — перенос строки, \r\n — Windows)
RECORD_DELIMITERS = ("\n", "\r\n", "\r")
EXTRACT_PROMPT = """Extract brand, model, engine_code, date_range from the text.
Return JSON object only: {"brand":"","model":"","engine_code":"","date_range":""}
Empty fields as "". No other text."""
def process_file(client: Client, filepath: pathlib.Path) -> bool:
"""Обрабатывает один файл. Возвращает True при успехе."""
content = filepath.read_text(encoding="utf-8")
out_path = RES_DIR / filepath.with_suffix(".json").name
def prepare_records(content: str) -> list[str]:
"""1) Подготовка списка записей из файла по разделителям."""
for sep in RECORD_DELIMITERS:
content = content.replace(sep, RECORD_DELIMITERS[0])
return [r.strip() for r in content.split(RECORD_DELIMITERS[0]) if r.strip()]
def extract_record(client: Client, record: str) -> dict:
"""2) Отправка промпт + 1 запись в нейронку. 3) Получение ответа."""
for attempt in range(1, RETRY_COUNT + 1):
try:
t0 = time.perf_counter()
print(f" {filepath.name}", end="", flush=True)
result = ""
stream = client.chat(
response = client.chat(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Convert to JSON. Output only the JSON array:\n\n{content}"},
{"role": "system", "content": EXTRACT_PROMPT},
{"role": "user", "content": record},
],
options={"num_ctx": NUM_CTX},
stream=True,
options={"num_ctx": NUM_CTX, "num_predict": NUM_PREDICT},
)
for chunk in stream:
part = chunk.get("message", {}).get("content", "") or ""
result += part
if part:
if LOG_STREAM:
sys.stdout.write(part)
else:
sys.stdout.write(".")
sys.stdout.flush()
elapsed = time.perf_counter() - t0
try:
data = json.loads(result)
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except json.JSONDecodeError:
out_path.write_text(result, encoding="utf-8")
print(f"\n" if LOG_STREAM else " ", end="")
print(f" {len(result)} chars, {elapsed:.1f}s — OK")
return True
except Exception as e:
print(f" {filepath.name} — попытка {attempt}/{RETRY_COUNT} — ошибка: {e}")
result = response.get("message", {}).get("content", "")
if "```" in result:
result = result.split("```")[1]
if result.strip().lower().startswith("json"):
result = result.strip()[4:].strip()
obj = json.loads(result)
return {
"brand": obj.get("brand", ""),
"model": obj.get("model", ""),
"engine_code": obj.get("engine_code", ""),
"date_range": obj.get("date_range", ""),
}
except (json.JSONDecodeError, KeyError) as e:
if attempt < RETRY_COUNT:
time.sleep(RETRY_DELAY)
else:
print(f" {filepath.name} — пропущен после {RETRY_COUNT} попыток")
return {"brand": "", "model": "", "engine_code": "", "date_range": record}
except Exception:
if attempt < RETRY_COUNT:
time.sleep(RETRY_DELAY)
else:
raise
return {"brand": "", "model": "", "engine_code": "", "date_range": record}
def process_file(client: Client, filepath: pathlib.Path) -> bool:
"""Обработка файла: подготовка → запрос по записи → добавление в JSON."""
content = filepath.read_text(encoding="utf-8")
records = prepare_records(content)
out_path = RES_DIR / filepath.with_suffix(".json").name
if not records:
print(f" {filepath.name} — 0 записей")
return False
return False
out_path.write_text("[]", encoding="utf-8")
total = len(records)
t0 = time.perf_counter()
data = []
for i, record in enumerate(records, 1):
print(f" [{i}/{total}] ", end="", flush=True)
obj = extract_record(client, record)
data.append(obj)
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if SHOW_IO:
inp = record[:60] + "" if len(record) > 60 else record
out = json.dumps(obj, ensure_ascii=False)
print(f"вход: {inp}", flush=True)
print(f" выход: {out}", flush=True)
else:
print("", flush=True)
elapsed = time.perf_counter() - t0
print(f" {filepath.name}{total} записей, {elapsed:.1f}s — OK")
return True
def main() -> None:
@@ -99,13 +124,7 @@ def main() -> None:
print(f"Файлов к обработке: {len(txt_files)}")
# Последовательная обработка: следующий файл только после завершения текущего
for filepath in txt_files:
out_path = RES_DIR / filepath.with_suffix(".json").name
if out_path.exists():
print(f" {filepath.name} — уже в res/, перенос в done/")
shutil.move(str(filepath), DONE_DIR / filepath.name)
continue
if process_file(client, filepath):
shutil.move(str(filepath), DONE_DIR / filepath.name)
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""
Локальный парсер без Ollama: читает .txt из lists/, парсит табуляцию,
сохраняет JSON в res/, переносит успешные в done/.
"""
import json
import pathlib
import shutil
LISTS_DIR = pathlib.Path(__file__).parent / "lists"
RES_DIR = pathlib.Path(__file__).parent / "res"
DONE_DIR = pathlib.Path(__file__).parent / "done"
def parse_line(line: str) -> dict:
parts = line.strip().split("\t")
return {
"brand": parts[0] if len(parts) > 0 else "",
"model": parts[1] if len(parts) > 1 else "",
"engine_code": parts[2] if len(parts) > 2 else "",
"date_range": parts[3] if len(parts) > 3 else "",
}
def main() -> None:
RES_DIR.mkdir(exist_ok=True)
DONE_DIR.mkdir(exist_ok=True)
for filepath in sorted(LISTS_DIR.glob("*.txt")):
content = filepath.read_text(encoding="utf-8")
lines = [ln for ln in content.splitlines() if ln.strip()]
data = [parse_line(ln) for ln in lines]
out_path = RES_DIR / filepath.with_suffix(".json").name
out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.move(str(filepath), DONE_DIR / filepath.name)
print(f" {filepath.name}{len(data)} записей — OK")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
[
{
"brand": "HYUNDAI",
"model": "Accent 1.3",
"engine_code": "G4E-A",
"date_range": "08.1999-"
},
{
"brand": "HYUNDAI",
"model": "Accent 1.3",
"engine_code": "G4EH",
"date_range": "08.2002-"
},
{
"brand": "HYUNDAI",
"model": "Accent 1.4 GL",
"engine_code": "",
"date_range": "11.2005-"
},
{
"brand": "HYUNDAI",
"model": "Accent 1.5",
"engine_code": "G4EB",
"date_range": "08.2002-"
},
{
"brand": "HYUNDAI",
"model": "Accent 1.6",
"engine_code": "G4ED-G",
"date_range": "12.2002-"
},
{
"brand": "HYUNDAI",
"model": "Accent 1.6 GLS",
"engine_code": "",
"date_range": "11.2005-"
},
{
"brand": "HYUNDAI",
"model": "Coupe 1.6 16V",
"engine_code": "G4ED-G",
"date_range": "03.2002-"
},
{
"brand": "HYUNDAI",
"model": "Coupe 2.0 16V",
"engine_code": "G4GF",
"date_range": "08.2002-"
},
{
"brand": "HYUNDAI",
"model": "Coupe 2.0 GLS",
"engine_code": "",
"date_range": "08.2002-"
},
{
"brand": "KIA",
"model": "Rio 1.6 16V",
"engine_code": "G4ED",
"date_range": "03.2005-"
},
{
"brand": "MERCEDES-BENZ",
"model": "CABRIOLET (W111) 280 SE 3.5",
"engine_code": "111.025",
"date_range": "01/1969 - 12/1971"
},
{
"brand": "MERCEDES-BENZ",
"model": "COUPE (W111) 280 SE 3.5",
"engine_code": "111.026",
"date_range": "01/1969 - 12/1971"
},
{
"brand": "MERCEDES-BENZ",
"model": "S-CLASS (W108, W109) 280 SE,SEL 3.5",
"engine_code": "M 116.980",
"date_range": "03/1971 - 08/1972"
},
{
"brand": "MERCEDES-BENZ",
"model": "S-CLASS (W108, W109) 300 SEL 3.5",
"engine_code": "109.056 M 116.981",
"date_range": "01/1970 - 07/1972"
},
{
"brand": "MERCEDES-BENZ",
"model": "S-CLASS (W116) 350 SE,SEL",
"engine_code": "M 116.983",
"date_range": "08/1972 - 02/1976"
},
{
"brand": "MERCEDES-BENZ",
"model": "SL купе (C107) 350 SLC (107.023)",
"engine_code": "M 116.982, M 116.984",
"date_range": "01/1972 - 02/1976"
}
]
+1 -1
View File
@@ -4,7 +4,7 @@ set -e
docker compose up -d --build ollama
echo "Ожидание загрузки модели..."
elapsed=0
until docker exec ollama ollama list 2>/dev/null | grep -q gemma3n; do
until docker exec ollama ollama list 2>/dev/null | grep -q "gemma-3n"; do
echo " ${elapsed}s — загрузка..."
sleep 10
elapsed=$((elapsed + 10))