0.4 - Прогон по одной записи, стрим в файл.
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user