diff --git a/README.md b/README.md index 6e9f8f3..bd8ef33 100644 --- a/README.md +++ b/README.md @@ -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` — показывать вход и выход по каждой записи | diff --git a/docker-compose.yml b/docker-compose.yml index 9b17396..cf08c23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/app-entrypoint.sh b/docker/app-entrypoint.sh index 0b85a68..011bfd4 100644 --- a/docker/app-entrypoint.sh +++ b/docker/app-entrypoint.sh @@ -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..." diff --git a/lists/02-example-long.txt b/done/02-example-long.txt similarity index 100% rename from lists/02-example-long.txt rename to done/02-example-long.txt diff --git a/lists/01-example-short.txt b/lists/01-example-short.txt index 5311e50..38ac8b0 100644 --- a/lists/01-example-short.txt +++ b/lists/01-example-short.txt @@ -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- \ No newline at end of file +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 \ No newline at end of file diff --git a/parse.py b/parse.py index 056ea5d..2d7e5cc 100644 --- a/parse.py +++ b/parse.py @@ -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") -# размер контекста (токенов для этой модели это максимум, больше контекст больше расход ОЗУ) -NUM_CTX = int(os.environ.get("NUM_CTX", "32768")) +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 False + 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} - 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: @@ -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) diff --git a/parse_local.py b/parse_local.py deleted file mode 100644 index 3c462c0..0000000 --- a/parse_local.py +++ /dev/null @@ -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() diff --git a/res/01-example-short.json b/res/01-example-short.json new file mode 100644 index 0000000..dcb4cc0 --- /dev/null +++ b/res/01-example-short.json @@ -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" + } +] \ No newline at end of file diff --git a/run.sh b/run.sh index 2866bfd..f898265 100755 --- a/run.sh +++ b/run.sh @@ -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))