Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# Скрипт парсинга спецдонесений МЧС
## Описание
`parse_reports.py` - скрипт для автоматического извлечения структурированных данных из спецдонесений МЧС Беларуси в формате .docx с использованием Claude API.
## Возможности
- ✅ Чтение .docx файлов через python-docx
- ✅ Извлечение 18 полей данных через Claude API
- ✅ Оценка уверенности (confidence) для каждого поля
- ✅ Автоматическая фильтрация по качеству данных
- ✅ Сохранение в PostgreSQL (таблицы cases + raw_documents)
- ✅ Режим dry-run для тестирования
- ✅ Детальное логирование и отчеты
## Извлекаемые поля
1. `age_years` - возраст в годах
2. `gender` - пол (мужской/женский)
3. `has_diagnosis` - наличие диагноза/заболевания
4. `diagnosis_type` - тип диагноза
5. `has_transport` - наличие транспорта
6. `season` - сезон (winter/spring/summer/autumn)
7. `time_of_day` - время суток (morning/day/evening/night)
8. `elapsed_before_report_h` - часов до сообщения
9. `last_seen_direction` - направление последнего наблюдения
10. `terrain_primary` - основной тип местности
11. `water_nearby` - водоем рядом
12. `road_nearby` - дорога рядом
13. `found_alive` - найден живым
14. `found_distance_km` - расстояние обнаружения (км)
15. `found_direction` - направление обнаружения
16. `found_location_type` - тип места обнаружения
17. `search_duration_hours` - длительность поиска (часы)
18. `who_found` - кто нашел
Каждое поле имеет:
- `value` - значение или null
- `confidence` - уровень уверенности (high/medium/low)
## Установка
### 1. Создание таблиц в БД
```bash
cd /root/sar-mchs/scripts
# Подключитесь к PostgreSQL и выполните:
psql -U postgres -d sar_mchs -f create_tables.sql
```
Или через Docker:
```bash
docker exec -i sar-mchs-postgres psql -U postgres -d sar_mchs < create_tables.sql
```
### 2. Установка зависимостей
Зависимости уже установлены в backend/requirements.txt:
- python-docx
- httpx
- sqlalchemy[asyncio]
- asyncpg
### 3. Настройка переменных окружения
```bash
export ANTHROPIC_API_KEY="your_api_key_here"
export DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/sar_mchs"
```
## Использование
### Базовое использование
```bash
cd /root/sar-mchs/scripts
# Dry-run (без записи в БД)
python3 parse_reports.py --dir /path/to/docx/files --dry-run
# Запись в БД
python3 parse_reports.py \
--dir /path/to/docx/files \
--db "postgresql+asyncpg://postgres:postgres@localhost:5432/sar_mchs"
```
### Параметры
- `--dir` (обязательный) - путь к папке с .docx файлами
- `--db` (опциональный) - DATABASE_URL для PostgreSQL
- `--dry-run` (флаг) - режим тестирования без записи в БД
### Примеры
**1. Тестовый запуск:**
```bash
python3 parse_reports.py --dir ./test_reports --dry-run
```
**2. Обработка с записью в БД:**
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
python3 parse_reports.py \
--dir /data/mchs_reports \
--db "postgresql+asyncpg://postgres:postgres@postgres:5432/sar_mchs"
```
**3. Обработка из Docker контейнера:**
```bash
docker exec -it sar-mchs-backend bash
cd /app/scripts
python3 parse_reports.py --dir /data/reports --db $DATABASE_URL
```
## Логика работы
1. **Чтение файлов**: Сканирует директорию на наличие .docx файлов
2. **Извлечение текста**: Использует python-docx для чтения содержимого
3. **Анализ через Claude**: Отправляет текст в Claude API с промптом извлечения
4. **Валидация**: Подсчитывает поля с high confidence
5. **Фильтрация**: Пропускает файлы с < 6 полей high confidence
6. **Сохранение**: Записывает в таблицы raw_documents и cases
7. **Отчет**: Генерирует parse_report.json с детальной статистикой
## Выходные данные
### Консольный вывод
```
2026-05-01 10:15:23 - INFO - Найдено файлов: 15
2026-05-01 10:15:23 - INFO - Режим: ЗАПИСЬ В БД
2026-05-01 10:15:24 - INFO - Обработка: report_001.docx
2026-05-01 10:15:28 - INFO - Полей с high confidence: 12/18
2026-05-01 10:15:29 - INFO - ✓ Сохранено в БД: report_001.docx (doc_id=1)
...
2026-05-01 10:20:15 - INFO - ============================================================
2026-05-01 10:20:15 - INFO - ИТОГОВАЯ СТАТИСТИКА
2026-05-01 10:20:15 - INFO - ============================================================
2026-05-01 10:20:15 - INFO - Всего файлов: 15
2026-05-01 10:20:15 - INFO - Успешно обработано: 12
2026-05-01 10:20:15 - INFO - Пропущено (< 6 high conf): 2
2026-05-01 10:20:15 - INFO - Ошибки: 1
```
### Файл отчета (parse_report.json)
```json
[
{
"file": "report_001.docx",
"status": "success",
"high_confidence_count": 12
},
{
"file": "report_002.docx",
"status": "skipped",
"reason": "Недостаточно полей с high confidence (4 < 6)",
"high_confidence_count": 4
}
]
```
## Структура БД
### Таблица raw_documents
- `id` - первичный ключ
- `filename` - имя файла
- `raw_text` - исходный текст
- `extracted_json` - JSON с извлеченными данными
- `created_at`, `updated_at` - временные метки
### Таблица cases
- `id` - первичный ключ
- `raw_document_id` - ссылка на raw_documents
- 18 полей данных (см. выше)
- `created_at`, `updated_at` - временные метки
## Ограничения и рекомендации
- **Rate limiting**: Скрипт делает паузу 1 секунду между запросами к Claude API
- **Длина текста**: Ограничена 8000 символами для промпта
- **Минимальный порог**: Файлы с < 6 полей high confidence пропускаются
- **Стоимость**: Каждый файл = 1 запрос к Claude API (~$0.003-0.015 за файл)
## Troubleshooting
**Ошибка: "ANTHROPIC_API_KEY не установлен"**
```bash
export ANTHROPIC_API_KEY="your_key_here"
```
**Ошибка подключения к БД**
```bash
# Проверьте DATABASE_URL
echo $DATABASE_URL
# Проверьте доступность PostgreSQL
docker exec sar-mchs-postgres pg_isready
```
**Файлы не найдены**
```bash
# Проверьте путь и наличие .docx файлов
ls -la /path/to/docx/files/*.docx
```
## TODO
- [ ] Добавить поддержку .pdf файлов
- [ ] Batch обработка для снижения стоимости API
- [ ] Retry логика при ошибках API
- [ ] Прогресс-бар для больших объемов
- [ ] Экспорт в CSV/Excel
- [ ] Web-интерфейс для мониторинга
+58
View File
@@ -0,0 +1,58 @@
-- Таблица для хранения сырых документов
CREATE TABLE IF NOT EXISTS raw_documents (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
raw_text TEXT NOT NULL,
extracted_json JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Таблица для хранения структурированных данных о случаях
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
raw_document_id INTEGER REFERENCES raw_documents(id),
-- Данные о пропавшем
age INTEGER,
gender VARCHAR(50),
has_diagnosis BOOLEAN,
diagnosis_type VARCHAR(255),
has_transport BOOLEAN,
-- Условия
season VARCHAR(50),
time_of_day VARCHAR(50),
elapsed_before_report_h FLOAT,
-- Местоположение
last_seen_direction VARCHAR(100),
terrain_primary VARCHAR(100),
water_nearby BOOLEAN,
road_nearby BOOLEAN,
-- Результаты поиска
found_alive BOOLEAN,
found_distance_km FLOAT,
found_direction VARCHAR(100),
found_location_type VARCHAR(255),
search_duration_hours FLOAT,
who_found VARCHAR(255),
-- Метаданные
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Индексы для ускорения запросов
CREATE INDEX IF NOT EXISTS idx_cases_age ON cases(age);
CREATE INDEX IF NOT EXISTS idx_cases_season ON cases(season);
CREATE INDEX IF NOT EXISTS idx_cases_terrain ON cases(terrain_primary);
CREATE INDEX IF NOT EXISTS idx_cases_found_alive ON cases(found_alive);
CREATE INDEX IF NOT EXISTS idx_cases_found_distance ON cases(found_distance_km);
-- Индекс для JSONB поиска
CREATE INDEX IF NOT EXISTS idx_raw_documents_json ON raw_documents USING gin(extracted_json);
COMMENT ON TABLE raw_documents IS 'Сырые документы спецдонесений МЧС';
COMMENT ON TABLE cases IS 'Структурированные данные о случаях пропажи людей';
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""
Скрипт для парсинга спецдонесений МЧС Беларуси из .docx файлов.
Использует Claude API для извлечения структурированных данных.
"""
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
import httpx
from docx import Document
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Список полей для извлечения
FIELDS = [
"age_years",
"gender",
"has_diagnosis",
"diagnosis_type",
"has_transport",
"season",
"time_of_day",
"elapsed_before_report_h",
"last_seen_direction",
"terrain_primary",
"water_nearby",
"road_nearby",
"found_alive",
"found_distance_km",
"found_direction",
"found_location_type",
"search_duration_hours",
"who_found"
]
EXTRACTION_PROMPT = """Ты — система извлечения данных из спецдонесений МЧС Беларуси. Извлеки поля и верни ТОЛЬКО JSON.
Поля для извлечения:
- age_years: возраст в годах (число)
- gender: пол (мужской/женский)
- has_diagnosis: есть ли диагноз/заболевание (true/false)
- diagnosis_type: тип диагноза если есть (строка)
- has_transport: был ли транспорт (true/false)
- season: сезон (winter/spring/summer/autumn)
- time_of_day: время суток (morning/day/evening/night)
- elapsed_before_report_h: часов прошло до сообщения (число)
- last_seen_direction: направление последнего наблюдения (строка)
- terrain_primary: основной тип местности (forest/field/urban/mountain/water)
- water_nearby: водоем рядом (true/false)
- road_nearby: дорога рядом (true/false)
- found_alive: найден живым (true/false)
- found_distance_km: расстояние обнаружения в км (число)
- found_direction: направление обнаружения (север/юг/восток/запад и т.д.)
- found_location_type: тип места обнаружения (строка)
- search_duration_hours: длительность поиска в часах (число)
- who_found: кто нашел (спасатели/волонтеры/родственники/сам вышел и т.д.)
Для каждого поля верни объект:
{
"value": <значение или null>,
"confidence": "high" | "medium" | "low"
}
Если поле не упомянуто в тексте — value: null, confidence: "low".
Верни ТОЛЬКО валидный JSON в формате:
{
"age_years": {"value": 65, "confidence": "high"},
"gender": {"value": "мужской", "confidence": "high"},
...
}
ТЕКСТ ДОНЕСЕНИЯ:
{text}
Ответь ТОЛЬКО JSON без дополнительного текста."""
async def extract_text_from_docx(file_path: Path) -> str:
"""Извлекает текст из .docx файла."""
try:
doc = Document(file_path)
text = "\n".join([paragraph.text for paragraph in doc.paragraphs])
return text.strip()
except Exception as e:
logger.error(f"Ошибка чтения {file_path}: {e}")
return ""
async def extract_data_with_claude(text: str, api_key: str) -> Optional[Dict]:
"""Извлекает структурированные данные из текста с помощью Claude API."""
prompt = EXTRACTION_PROMPT.format(text=text[:8000]) # Ограничиваем длину
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
json={
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"messages": [
{
"role": "user",
"content": prompt
}
]
}
)
if response.status_code != 200:
logger.error(f"Claude API error: {response.status_code} - {response.text}")
return None
result = response.json()
content = result["content"][0]["text"]
# Убираем markdown блоки если есть
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
data = json.loads(content)
return data
except Exception as e:
logger.error(f"Ошибка извлечения данных: {e}")
return None
def count_high_confidence_fields(data: Dict) -> int:
"""Подсчитывает количество полей с high confidence."""
count = 0
for field in FIELDS:
if field in data and data[field].get("confidence") == "high":
count += 1
return count
async def save_to_database(
file_path: Path,
raw_text: str,
extracted_data: Dict,
db_url: str
) -> bool:
"""Сохраняет данные в PostgreSQL."""
try:
# Создаем async engine
engine = create_async_engine(db_url, echo=False)
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
# Сохраняем raw документ
raw_doc_query = text("""
INSERT INTO raw_documents (filename, raw_text, extracted_json, created_at)
VALUES (:filename, :raw_text, :extracted_json, NOW())
RETURNING id
""")
result = await session.execute(
raw_doc_query,
{
"filename": file_path.name,
"raw_text": raw_text,
"extracted_json": json.dumps(extracted_data, ensure_ascii=False)
}
)
doc_id = result.scalar_one()
# Подготавливаем данные для cases
case_data = {
"raw_document_id": doc_id,
"age": extracted_data.get("age_years", {}).get("value"),
"gender": extracted_data.get("gender", {}).get("value"),
"has_diagnosis": extracted_data.get("has_diagnosis", {}).get("value"),
"diagnosis_type": extracted_data.get("diagnosis_type", {}).get("value"),
"has_transport": extracted_data.get("has_transport", {}).get("value"),
"season": extracted_data.get("season", {}).get("value"),
"time_of_day": extracted_data.get("time_of_day", {}).get("value"),
"elapsed_before_report_h": extracted_data.get("elapsed_before_report_h", {}).get("value"),
"last_seen_direction": extracted_data.get("last_seen_direction", {}).get("value"),
"terrain_primary": extracted_data.get("terrain_primary", {}).get("value"),
"water_nearby": extracted_data.get("water_nearby", {}).get("value"),
"road_nearby": extracted_data.get("road_nearby", {}).get("value"),
"found_alive": extracted_data.get("found_alive", {}).get("value"),
"found_distance_km": extracted_data.get("found_distance_km", {}).get("value"),
"found_direction": extracted_data.get("found_direction", {}).get("value"),
"found_location_type": extracted_data.get("found_location_type", {}).get("value"),
"search_duration_hours": extracted_data.get("search_duration_hours", {}).get("value"),
"who_found": extracted_data.get("who_found", {}).get("value")
}
# Сохраняем case
case_query = text("""
INSERT INTO cases (
raw_document_id, age, gender, has_diagnosis, diagnosis_type,
has_transport, season, time_of_day, elapsed_before_report_h,
last_seen_direction, terrain_primary, water_nearby, road_nearby,
found_alive, found_distance_km, found_direction, found_location_type,
search_duration_hours, who_found, created_at
)
VALUES (
:raw_document_id, :age, :gender, :has_diagnosis, :diagnosis_type,
:has_transport, :season, :time_of_day, :elapsed_before_report_h,
:last_seen_direction, :terrain_primary, :water_nearby, :road_nearby,
:found_alive, :found_distance_km, :found_direction, :found_location_type,
:search_duration_hours, :who_found, NOW()
)
""")
await session.execute(case_query, case_data)
await session.commit()
logger.info(f"✓ Сохранено в БД: {file_path.name} (doc_id={doc_id})")
return True
except Exception as e:
logger.error(f"Ошибка сохранения в БД: {e}")
return False
async def process_file(
file_path: Path,
api_key: str,
db_url: Optional[str],
dry_run: bool
) -> Dict:
"""Обрабатывает один файл."""
logger.info(f"Обработка: {file_path.name}")
# Извлекаем текст
text = await extract_text_from_docx(file_path)
if not text:
return {
"file": file_path.name,
"status": "error",
"reason": "Не удалось извлечь текст"
}
# Извлекаем данные через Claude
extracted_data = await extract_data_with_claude(text, api_key)
if not extracted_data:
return {
"file": file_path.name,
"status": "error",
"reason": "Ошибка извлечения данных"
}
# Подсчитываем high confidence поля
high_conf_count = count_high_confidence_fields(extracted_data)
logger.info(f" Полей с high confidence: {high_conf_count}/{len(FIELDS)}")
# Проверяем минимальный порог
if high_conf_count < 6:
logger.warning(f" ⚠ Пропущено: недостаточно полей с high confidence ({high_conf_count} < 6)")
return {
"file": file_path.name,
"status": "skipped",
"reason": f"Недостаточно полей с high confidence ({high_conf_count} < 6)",
"high_confidence_count": high_conf_count
}
# Сохраняем в БД если не dry-run
if not dry_run and db_url:
success = await save_to_database(file_path, text, extracted_data, db_url)
status = "success" if success else "db_error"
else:
logger.info(f" [DRY RUN] Данные не сохранены в БД")
status = "dry_run"
return {
"file": file_path.name,
"status": status,
"high_confidence_count": high_conf_count,
"extracted_data": extracted_data if dry_run else None
}
async def main():
parser = argparse.ArgumentParser(
description="Парсинг спецдонесений МЧС из .docx файлов"
)
parser.add_argument(
"--dir",
required=True,
help="Папка с .docx файлами"
)
parser.add_argument(
"--db",
help="DATABASE_URL для PostgreSQL"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Не записывать в БД, только показать результаты"
)
args = parser.parse_args()
# Проверяем API ключ
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
logger.error("ANTHROPIC_API_KEY не установлен в переменных окружения")
sys.exit(1)
# Проверяем директорию
dir_path = Path(args.dir)
if not dir_path.exists() or not dir_path.is_dir():
logger.error(f"Директория не найдена: {args.dir}")
sys.exit(1)
# Получаем список .docx файлов
docx_files = list(dir_path.glob("*.docx"))
if not docx_files:
logger.warning(f"Не найдено .docx файлов в {args.dir}")
sys.exit(0)
logger.info(f"Найдено файлов: {len(docx_files)}")
logger.info(f"Режим: {'DRY RUN' if args.dry_run else 'ЗАПИСЬ В БД'}")
# Обрабатываем файлы
results = []
for file_path in docx_files:
result = await process_file(file_path, api_key, args.db, args.dry_run)
results.append(result)
# Небольшая пауза между запросами к API
await asyncio.sleep(1)
# Выводим итоговую статистику
logger.info("\n" + "="*60)
logger.info("ИТОГОВАЯ СТАТИСТИКА")
logger.info("="*60)
success_count = sum(1 for r in results if r["status"] == "success")
skipped_count = sum(1 for r in results if r["status"] == "skipped")
error_count = sum(1 for r in results if r["status"] == "error")
dry_run_count = sum(1 for r in results if r["status"] == "dry_run")
logger.info(f"Всего файлов: {len(results)}")
logger.info(f"Успешно обработано: {success_count}")
logger.info(f"Пропущено (< 6 high conf): {skipped_count}")
logger.info(f"Ошибки: {error_count}")
if dry_run_count > 0:
logger.info(f"Dry run: {dry_run_count}")
# Сохраняем детальный отчет
report_path = Path("parse_report.json")
with open(report_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"\nДетальный отчет сохранен в: {report_path}")
if __name__ == "__main__":
asyncio.run(main())