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 файлы - `lists/` — необработанные .txt файлы
- `done/` — обработанные файлы (переносятся из lists/) - `done/` — обработанные (переносятся из lists/)
- `res/` — результат в JSON - `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 ```bash
pip install -r requirements.txt pip install -r requirements.txt
python3 parse.py 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: ports:
- "11435:11434" # 11435 — если локальный Ollama уже на 11434 - "11435:11434" # 11435 — если локальный Ollama уже на 11434
environment: environment:
OLLAMA_MODEL: gemma3n:e4b OLLAMA_MODEL: mithunrajm06/gemma-3n-e4b:latest
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
ollama serve & ollama serve &
sleep 10 sleep 10
for i in 1 2 3 4 5 6 7 8 9 10; do 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 sleep 5
done done
wait wait
@@ -28,7 +28,7 @@ services:
- ./done:/app/done - ./done:/app/done
environment: environment:
OLLAMA_HOST: http://ollama:11434 OLLAMA_HOST: http://ollama:11434
MODEL: gemma3n:e4b MODEL: mithunrajm06/gemma-3n-e4b:latest
depends_on: depends_on:
ollama: ollama:
condition: service_started condition: service_started
+1 -1
View File
@@ -2,7 +2,7 @@
set -e set -e
OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}"
MODEL="${MODEL:-gemma3n:e4b}" MODEL="${MODEL:-mithunrajm06/gemma-3n-e4b:latest}"
MODEL_NAME="${MODEL%%:*}" MODEL_NAME="${MODEL%%:*}"
echo "Waiting for Ollama..." echo "Waiting for Ollama..."
+12 -3
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.3 [G4E-A] 08.1999-
HYUNDAI Accent 1.5 [G4EB] 08.2002- HYUNDAI Accent 1.6 [G4ED-G] 12.2002- 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 Accent 1.6 GLS 11.2005-
HYUNDAI Coupe 1.6 16V [G4ED-G] 03.2002- HYUNDAI Coupe 1.6 16V [G4ED-G] 03.2002-
HYUNDAI Coupe 2.0 16V [G4GF] 08.2002- HYUNDAI Coupe 2.0 16V [G4GF] 08.2002-
HYUNDAI Coupe 2.0 GLS 08.2002- HYUNDAI Coupe 2.0 GLS 08.2002-
KIA Rio 1.6 16V [G4ED] 03.2005- 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
+76 -57
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Скрипт загружает .txt файлы из lists/, отправляет каждый в локальную Ollama Скрипт загружает .txt файлы из lists/, разбивает по разделителю,
с системным промптом и сохраняет ответ в res/. Успешные файлы переносятся в done/. отправляет каждую запись в Ollama, собирает JSON в res/.
При падении можно перезапустить — необработанные остаются в lists/.
""" """
import json import json
@@ -19,71 +18,97 @@ LISTS_DIR = pathlib.Path(__file__).parent / "lists"
RES_DIR = pathlib.Path(__file__).parent / "res" RES_DIR = pathlib.Path(__file__).parent / "res"
DONE_DIR = pathlib.Path(__file__).parent / "done" DONE_DIR = pathlib.Path(__file__).parent / "done"
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434") 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_CTX = int(os.environ.get("NUM_CTX", "32768")) NUM_PREDICT = int(os.environ.get("NUM_PREDICT", "512"))
RETRY_COUNT = 3 RETRY_COUNT = 3
RETRY_DELAY = 5 RETRY_DELAY = 5
LOG_STREAM = os.environ.get("LOG_STREAM", "").lower() in ("1", "true", "yes") 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. # Разделители записей при разбиении файла (\n — перенос строки, \r\n — Windows)
The input text is a list of tab-separated lines: brand, model, engine_code, date_range. RECORD_DELIMITERS = ("\n", "\r\n", "\r")
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. EXTRACT_PROMPT = """Extract brand, model, engine_code, date_range from the text.
Keep empty fields as empty string "". Return JSON object only: {"brand":"","model":"","engine_code":"","date_range":""}
Output ONLY the JSON array. No explanations, no markdown, no other text before or after.""" Empty fields as "". No other text."""
def process_file(client: Client, filepath: pathlib.Path) -> bool: def prepare_records(content: str) -> list[str]:
"""Обрабатывает один файл. Возвращает True при успехе.""" """1) Подготовка списка записей из файла по разделителям."""
content = filepath.read_text(encoding="utf-8") for sep in RECORD_DELIMITERS:
out_path = RES_DIR / filepath.with_suffix(".json").name 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): for attempt in range(1, RETRY_COUNT + 1):
try: try:
t0 = time.perf_counter() response = client.chat(
print(f" {filepath.name}", end="", flush=True)
result = ""
stream = client.chat(
model=MODEL, model=MODEL,
messages=[ messages=[
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": EXTRACT_PROMPT},
{"role": "user", "content": f"Convert to JSON. Output only the JSON array:\n\n{content}"}, {"role": "user", "content": record},
], ],
options={"num_ctx": NUM_CTX}, options={"num_ctx": NUM_CTX, "num_predict": NUM_PREDICT},
stream=True,
) )
for chunk in stream: result = response.get("message", {}).get("content", "")
part = chunk.get("message", {}).get("content", "") or "" if "```" in result:
result += part result = result.split("```")[1]
if part: if result.strip().lower().startswith("json"):
if LOG_STREAM: result = result.strip()[4:].strip()
sys.stdout.write(part) obj = json.loads(result)
else: return {
sys.stdout.write(".") "brand": obj.get("brand", ""),
sys.stdout.flush() "model": obj.get("model", ""),
"engine_code": obj.get("engine_code", ""),
elapsed = time.perf_counter() - t0 "date_range": obj.get("date_range", ""),
try: }
data = json.loads(result) except (json.JSONDecodeError, KeyError) as e:
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}")
if attempt < RETRY_COUNT: if attempt < RETRY_COUNT:
time.sleep(RETRY_DELAY) time.sleep(RETRY_DELAY)
else: else:
print(f" {filepath.name} — пропущен после {RETRY_COUNT} попыток") return {"brand": "", "model": "", "engine_code": "", "date_range": record}
return False except Exception:
if attempt < RETRY_COUNT:
time.sleep(RETRY_DELAY)
else:
raise
return {"brand": "", "model": "", "engine_code": "", "date_range": record}
return False
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
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: def main() -> None:
@@ -99,13 +124,7 @@ def main() -> None:
print(f"Файлов к обработке: {len(txt_files)}") print(f"Файлов к обработке: {len(txt_files)}")
# Последовательная обработка: следующий файл только после завершения текущего
for filepath in 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): if process_file(client, filepath):
shutil.move(str(filepath), DONE_DIR / filepath.name) 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 docker compose up -d --build ollama
echo "Ожидание загрузки модели..." echo "Ожидание загрузки модели..."
elapsed=0 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 — загрузка..." echo " ${elapsed}s — загрузка..."
sleep 10 sleep 10
elapsed=$((elapsed + 10)) elapsed=$((elapsed + 10))