From 2d4eff6c3f218d0e7167e8060641d88b76641da9 Mon Sep 17 00:00:00 2001 From: Shuvalov Evgeny Date: Sun, 1 Feb 2026 22:02:49 +0300 Subject: [PATCH] fix --- 1_анализ_главы/run_chapter_analysis_ollama.py | 4 +- 2_валидация_анализа_по_блокам/README.md | 24 ++ .../run_validation_ollama.py | 236 +++++++++++++++++ .../README.md | 22 +- .../merge.json | 159 ----------- .../merged_with_tags.json | 248 ------------------ .../run_consistency_ollama.py | 185 +++++++++++++ 3_извлечение_тегов/README.md | 27 +- 3_извлечение_тегов/allowed_tags.example.json | 31 +++ 3_извлечение_тегов/run_extract_tags_ollama.py | 224 ++++++++++++++++ 4_валидация_тегов/README.md | 23 +- 4_валидация_тегов/run_validate_tags_ollama.py | 216 +++++++++++++++ 5_мерж_анализа_и_тегов/README.md | 29 +- .../run_merge_analysis_tags.py | 95 +++++++ 6_генерация_эмбеддингов/README.md | 25 +- 6_генерация_эмбеддингов/run_embed_ollama.py | 142 ++++++++++ 7_сохранение_qdrant/README.md | 8 +- check_postgres_schema.py | 42 +++ 18 files changed, 1321 insertions(+), 419 deletions(-) create mode 100644 2_валидация_анализа_по_блокам/run_validation_ollama.py delete mode 100644 2b_финальная_валидация_согласованности/merge.json delete mode 100644 2b_финальная_валидация_согласованности/merged_with_tags.json create mode 100644 2b_финальная_валидация_согласованности/run_consistency_ollama.py create mode 100644 3_извлечение_тегов/allowed_tags.example.json create mode 100644 3_извлечение_тегов/run_extract_tags_ollama.py create mode 100644 4_валидация_тегов/run_validate_tags_ollama.py create mode 100644 5_мерж_анализа_и_тегов/run_merge_analysis_tags.py create mode 100644 6_генерация_эмбеддингов/run_embed_ollama.py create mode 100644 check_postgres_schema.py diff --git a/1_анализ_главы/run_chapter_analysis_ollama.py b/1_анализ_главы/run_chapter_analysis_ollama.py index 7d28df8..71f85cb 100644 --- a/1_анализ_главы/run_chapter_analysis_ollama.py +++ b/1_анализ_главы/run_chapter_analysis_ollama.py @@ -19,8 +19,8 @@ DIR = Path(__file__).resolve().parent OLLAMA_OPTIONS = { "temperature": 0.3, - "num_ctx": 8500, - "num_predict": 4096, + "num_ctx": 9000, + "num_predict": 8500, "repeat_penalty": 1.1, } diff --git a/2_валидация_анализа_по_блокам/README.md b/2_валидация_анализа_по_блокам/README.md index 1f2a89a..a226ad6 100644 --- a/2_валидация_анализа_по_блокам/README.md +++ b/2_валидация_анализа_по_блокам/README.md @@ -22,3 +22,27 @@ - `{limitations_json}` — JSON блока `limitations` Промпты рассчитаны на тестирование по одному; доработка — по результатам прогонов. + +## Скрипт валидации через Ollama + +`run_validation_ollama.py` — последовательная валидация всех четырёх блоков через Ollama. На выходе один JSON-файл со статусами по каждому блоку. + +**Вход (по умолчанию):** +- `../1_анализ_главы/merge.json` — результат этапа 1 (framework, insights, application, limitations) +- `../1_анализ_главы/вход_главы.json` — текст главы и метаданные (book_title, chapter_title, chapter_text) + +**Выход:** `validation_status.json` в каталоге скрипта (или путь через `-o`). + +**Формат выхода:** по одному ключу на блок (`framework`, `insights`, `application`, `limitations`). Каждое значение: +- `verdict` — `ok` | `needs_review` | `bad` | `skipped` | `error` +- `score` — число 0.0 или `null` +- `hallucinations` — список замечаний по галлюцинациям +- `missing_key_points` — список пропущенных ключевых моментов + +**Запуск:** +```bash +cd 2_валидация_анализа_по_блокам +python3 run_validation_ollama.py +# с указанием путей: +python3 run_validation_ollama.py --merge /path/to/merge.json --chapter /path/to/вход_главы.json -o validation_status.json +``` diff --git a/2_валидация_анализа_по_блокам/run_validation_ollama.py b/2_валидация_анализа_по_блокам/run_validation_ollama.py new file mode 100644 index 0000000..4b18868 --- /dev/null +++ b/2_валидация_анализа_по_блокам/run_validation_ollama.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Валидация анализа главы по блокам через Ollama: framework → insights → application → limitations. +Вход: merge.json (анализ из этапа 1), вход_главы.json (текст главы и метаданные). +Выход: один JSON-файл со статусами по каждому блоку (verdict, score, hallucinations, missing_key_points). +""" + +import argparse +import json +import re +import sys +import time +import urllib.request +from pathlib import Path + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen3:14b" +DIR = Path(__file__).resolve().parent +DEFAULT_MERGE = DIR.parent / "1_анализ_главы" / "merge.json" +DEFAULT_CHAPTER = DIR.parent / "1_анализ_главы" / "вход_главы.json" + +OLLAMA_OPTIONS = { + "temperature": 0.2, + "num_ctx": 8500, + "num_predict": 2048, + "repeat_penalty": 1.1, +} + +BLOCKS = [ + ("framework", "validate_framework.txt", "framework_json"), + ("insights", "validate_insights.txt", "insights_json"), + ("application", "validate_application.txt", "application_json"), + ("limitations", "validate_limitations.txt", "limitations_json"), +] + + +def load_json(path: Path) -> dict: + """Загружает JSON из файла.""" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def load_prompt(filename: str) -> str: + """Загружает шаблон промпта из файла.""" + with open(DIR / filename, encoding="utf-8") as f: + return f.read() + + +def substitute_prompt( + prompt: str, + book_title: str, + chapter_title: str, + chapter_text: str, + block_json: str, + block_placeholder: str, +) -> str: + """Подставляет в промпт поля главы и JSON блока. block_placeholder — например {framework_json}.""" + return ( + prompt.replace("{book_title}", book_title) + .replace("{chapter_title}", chapter_title) + .replace("{chapter_text}", chapter_text) + .replace(block_placeholder, block_json) + ) + + +def extract_json_from_response(text: str) -> dict: + """Достаёт JSON из ответа модели (может быть обёрнут в ```json ... ```).""" + text = text.strip() + match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) + if match: + text = match.group(1).strip() + return json.loads(text) + + +def call_ollama(prompt: str) -> str: + """Вызывает Ollama /api/chat и возвращает content ответа.""" + body = json.dumps( + { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "format": "json", + "options": OLLAMA_OPTIONS, + "keep_alive": 0, + }, + ensure_ascii=False, + ).encode("utf-8") + req = urllib.request.Request( + f"{OLLAMA_URL}/api/chat", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=None) as resp: + data = json.load(resp) + return data.get("message", {}).get("content", "") + except urllib.error.HTTPError as e: + body_b = b"" + if e.fp: + try: + body_b = e.fp.read()[:1000] + except Exception: + pass + raise RuntimeError( + f"Ollama HTTP {e.code}: {e.reason}. Body: {body_b.decode('utf-8', errors='replace')}" + ) from e + + +def main() -> int: + """Последовательно валидирует 4 блока и пишет один JSON со статусами.""" + parser = argparse.ArgumentParser( + description="Валидация анализа главы по блокам через Ollama. Выход — JSON со статусами." + ) + parser.add_argument( + "--merge", + type=Path, + default=DEFAULT_MERGE, + help=f"Путь к merge.json (по умолчанию: {DEFAULT_MERGE})", + ) + parser.add_argument( + "--chapter", + type=Path, + default=DEFAULT_CHAPTER, + help=f"Путь к вход_главы.json (по умолчанию: {DEFAULT_CHAPTER})", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DIR / "validation_status.json", + help="Путь к выходному JSON со статусами (по умолчанию: validation_status.json)", + ) + args = parser.parse_args() + + if not args.merge.is_file(): + print(f"Файл не найден: {args.merge}", file=sys.stderr) + return 1 + if not args.chapter.is_file(): + print(f"Файл не найден: {args.chapter}", file=sys.stderr) + return 1 + + print("Загрузка merge.json и вход_главы.json...") + merge = load_json(args.merge) + chapter = load_json(args.chapter) + book_title = chapter.get("book_title", "") + chapter_title = chapter.get("chapter_title", "") + chapter_text = chapter.get("chapter_text", "") + + results: dict = {} + + for block_name, prompt_file, json_placeholder in BLOCKS: + block_data = merge.get(block_name) + if block_data is None: + print(f"Блок «{block_name}» отсутствует в merge.json, пропуск.", file=sys.stderr) + results[block_name] = { + "verdict": "skipped", + "score": None, + "hallucinations": [], + "missing_key_points": [], + "error": "block not found in merge", + } + continue + + prompt_tpl = load_prompt(prompt_file) + block_json_str = json.dumps(block_data, ensure_ascii=False, indent=2) + placeholder = "{" + json_placeholder + "}" # {framework_json}, {insights_json}, ... + prompt = substitute_prompt( + prompt_tpl, + book_title, + chapter_title, + chapter_text, + block_json_str, + placeholder, + ) + + print(f"Валидация блока «{block_name}»...") + t0 = time.monotonic() + try: + raw = call_ollama(prompt) + except Exception as e: + print(f"Ошибка вызова Ollama на блоке «{block_name}»: {e}", file=sys.stderr) + results[block_name] = { + "verdict": "error", + "score": None, + "hallucinations": [], + "missing_key_points": [], + "error": str(e), + } + continue + elapsed = time.monotonic() - t0 + print(f" Ответ за {elapsed:.1f} сек ({elapsed / 60:.1f} мин)") + + try: + block_result = extract_json_from_response(raw) + except json.JSONDecodeError as e: + print( + f"Не удалось распарсить JSON в блоке «{block_name}»: {e}", + file=sys.stderr, + ) + print("Первые 500 символов ответа:", raw[:500], file=sys.stderr) + results[block_name] = { + "verdict": "error", + "score": None, + "hallucinations": [], + "missing_key_points": [], + "error": f"JSON decode: {e}", + } + continue + + if not isinstance(block_result, dict): + results[block_name] = { + "verdict": "error", + "score": None, + "hallucinations": [], + "missing_key_points": [], + "error": f"expected dict, got {type(block_result).__name__}", + } + continue + + results[block_name] = { + "verdict": block_result.get("verdict", "unknown"), + "score": block_result.get("score"), + "hallucinations": block_result.get("hallucinations", []), + "missing_key_points": block_result.get("missing_key_points", []), + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"Записано: {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/2b_финальная_валидация_согласованности/README.md b/2b_финальная_валидация_согласованности/README.md index ba1a337..b44a8e4 100644 --- a/2b_финальная_валидация_согласованности/README.md +++ b/2b_финальная_валидация_согласованности/README.md @@ -14,7 +14,27 @@ - `{chapter_title}` — название главы - `{full_analysis_json}` — полный JSON анализа (склейка блоков framework + insights + application + limitations) -## Использование +## Скрипт через Ollama + +`run_consistency_ollama.py` — один вызов Ollama для проверки согласованности блоков. На выходе один JSON-файл. + +**Вход (по умолчанию):** +- `../1_анализ_главы/merge.json` — полный анализ (framework, insights, application, limitations) +- `../1_анализ_главы/вход_главы.json` — метаданные главы (book_title, chapter_title) + +**Выход:** `consistency_result.json` в каталоге скрипта (или путь через `-o`). + +**Формат выхода:** `verdict` (ok | needs_review | bad), `score`, `inconsistencies` — список рассогласованностей с полями type, location, summary. + +**Запуск:** +```bash +cd 2b_финальная_валидация_согласованности +python3 run_consistency_ollama.py +# с указанием путей: +python3 run_consistency_ollama.py --merge /path/to/merge.json --chapter /path/to/вход_главы.json -o consistency_result.json +``` + +## Использование в пайплайне Вызывается после шага 2a (склейка блоков). При вердикте bad или needs_review — флаг `needs_review` по главе; опционально — «примиряющий» промпт для минимальных правок. diff --git a/2b_финальная_валидация_согласованности/merge.json b/2b_финальная_валидация_согласованности/merge.json deleted file mode 100644 index e130b5e..0000000 --- a/2b_финальная_валидация_согласованности/merge.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "framework": { - "terms": { - "стимул": "что в окружающей среде заставляет человека действовать", - "привычка": "повторяющееся действие, которое становится автоматическим", - "подкрепление": "воздействие, усиливающее вероятность повторения действия", - "среда": "окружение, влияющее на поведение через стимулы и ограничения", - "желание, вызванное стимулом": "непроизвольная тяга к действию, спровоцированному внешним триггером", - "аутокаталитический процесс": "процесс, усиливающий себя сам" - }, - "principles": [ - { - "title": "Среда формирует поведение", - "description": "Человек не управляет привычками напрямую, а воздействует на окружение, чтобы оно вело его к нужным действиям", - "example": "Убрать телефон с рабочего стола, чтобы не отвлекаться на соцсети", - "chains": [ - { - "cause": "Человек находится в среде с доступными стимулами для нежелательной привычки", - "mechanism": "Стимул активирует уже сформированную привычку, которая запускает автоматическое действие", - "result": "Человек повторяет вредную привычку, не осознавая этого" - }, - { - "cause": "Изменяется среда, удаляются стимулы, провоцирующие нежелательную привычку", - "mechanism": "Отсутствие триггеров снижает вероятность срабатывания привычки", - "result": "Человек реже проявляет нежелательное поведение" - } - ] - }, - { - "title": "Привычки не исчезают, они только временно подавлены", - "description": "Даже если человек перестаёт делать что-то, привычка остаётся в мозге и может вернуться при подходящих условиях", - "example": "Человек перестаёт курить, но снова начинает после посещения места, где раньше курил", - "chains": [ - { - "cause": "Человек прекращает проявлять вредную привычку", - "mechanism": "Мозг сохраняет нейронные связи, связанные с этой привычкой", - "result": "Привычка может вернуться, если появятся подходящие стимулы" - }, - { - "cause": "Окружение меняется, и в нем появляются триггеры, связанные с прошлой привычкой", - "mechanism": "Стимулы запускают автоматические паттерны поведения", - "result": "Человек снова начинает делать то, что уже перестал" - } - ] - }, - { - "title": "Самоконтроль — это краткосрочный, а не долгосрочный инструмент", - "description": "Противостояние искушению неэффективно на длительном сроке, нужно менять контекст", - "example": "Человек отказывается от сладкого, но возвращается к нему после стрессового дня", - "chains": [ - { - "cause": "Человек пытается контролировать себя в момент искушения", - "mechanism": "Это требует энергии и усилий, которые невозможно поддерживать всегда", - "result": "Человек устаёт от борьбы и возвращается к нежелательной привычке" - }, - { - "cause": "Человек изменяет окружение, чтобы искушения стали менее доступными", - "mechanism": "Стимулы для плохого поведения становятся незаметными или невозможными", - "result": "Человек реже сталкивается с искушением" - } - ] - }, - { - "title": "Дисциплина — это настройка среды, а не внутреннее усилие", - "description": "Сильные люди не просто дисциплинированы, а живут в среде, которая поддерживает их цели", - "example": "Человек встаёт раньше, потому что спальня тёмная и не имеет гаджетов", - "chains": [ - { - "cause": "Человек пытается быть дисциплинированным только силой воли", - "mechanism": "Это требует большого количества энергии и часто неудачно", - "result": "Человек теряет мотивацию и возвращается к нежелательному поведению" - }, - { - "cause": "Человек настраивает окружение так, чтобы хорошее поведение было лёгким", - "mechanism": "Стимулы для правильных действий становятся более доступными", - "result": "Человек чаще выбирает здоровые привычки" - } - ] - }, - { - "title": "Негативные эмоции усиливают негативное поведение", - "description": "Стресс, тревога и другие негативные чувства провоцируют возвращение к вредным привычкам", - "example": "Человек садится на диван и ест шоколад, когда ему плохо", - "chains": [ - { - "cause": "Человек испытывает стресс или негативные эмоции", - "mechanism": "Это активирует привычку, которая помогает снизить дискомфорт", - "result": "Человек снова начинает делать то, что раньше снимало напряжение" - }, - { - "cause": "Человек пытается избавиться от вредной привычки, но не меняет среду", - "mechanism": "Стресс и негативные эмоции остаются, усиливая желание к действию", - "result": "Человек возвращается к нежелательной привычке" - } - ] - } - ] - }, - "insights": [ - { - "title": "Среда влияет на привычки больше, чем сила воли", - "description": "Чтобы изменить привычку, нужно изменить окружение, а не надеяться только на самоконтроль", - "example": "Убрать телефон с рабочего стола, чтобы не отвлекаться на соцсети" - }, - { - "title": "Привычки не исчезают, только временно подавляются", - "description": "Даже если человек перестаёт курить или есть сладкое, эти привычки остаются в мозге и могут вернуться при подходящих условиях", - "example": "После посещения бара человек снова начинает пить, не осознавая этого" - }, - { - "title": "Негативные эмоции усиливают вредное поведение", - "description": "Стресс, тревога и другие негативные чувства могут спровоцировать возвращение к старым привычкам", - "example": "После ссоры человек снова начинает есть вредную пищу, чтобы успокоиться" - }, - { - "title": "Сокращение стимулов уменьшает повторение нежелательного поведения", - "description": "Удаление триггеров, связанных с вредной привычкой, снижает вероятность её возобновления", - "example": "Убрать сигареты из дома, чтобы уменьшить вероятность их употребления" - }, - { - "title": "Самоконтроль — это временная стратегия, а не долгосрочное решение", - "description": "Постоянно бороться с искушением утомительно и непрочное; нужно настраивать среду так, чтобы искушений не было совсем", - "example": "Поставить телефон в другую комнату, чтобы не проверять соцсети во время работы" - } - ], - "application": { - "techniques": [ - { - "name": "Снижение доступности стимула", - "goal": "Уменьшение вероятности повторения нежелательной привычки за счёт удаления её триггеров", - "context_example": "Вечер после работы, когда тянет залипнуть в телефон и проверять соцсети", - "steps": [ - "Определите конкретный стимул (например, наличие телефона на столе)", - "Уберите объект из зоны видимости или досягаемости (например, оставьте телефон в другой комнате)", - "Запишите дату и время изменения (для отслеживания эффективности)" - ], - "client_phrase": "Уберите источник искушения из вашей среды — действие станет менее вероятным", - "success_criteria": "Частота использования телефона в моменты, когда это не нужно, снижается с 5 раз/день до 1–2 раз/день за 2 недели" - } - ] - }, - "limitations": [ - { - "description": "Идеи не работают, если триггер (то, что в окружении запускает действие) не устранён или остаётся доступным, так как привычка может вернуться при появлении стимула", - "when_relevant": "Когда человек не полностью убирает триггер из своей среды или сталкивается с ним в новой обстановке", - "example": "Человек удалил сигареты из дома, но снова начинает курить на работе среди коллег-курильщиков" - }, - { - "description": "Техники могут не сработать, если негативные эмоции (стресс, тревога) остаются необратленными и усиливают нежелательное поведение", - "when_relevant": "Когда человек продолжает испытывать сильный стресс или эмоциональные трудности, не решая их на уровне среды", - "example": "Человек убрал телефон из комнаты, но всё равно переедает перед сном из-за хронической тревоги" - }, - { - "description": "Изменения среды могут быть недостаточны, если привычка закреплена на уровне автоматических нейронных паттернов и не сопровождается новыми, здоровыми привычками", - "when_relevant": "Когда человек устраняет триггер, но не вводит альтернативное поведение для подкрепления", - "example": "Человек убрал телевизор из спальни, но не нашёл замену в виде чтения или медитации и проводит время впустую" - } - ] -} \ No newline at end of file diff --git a/2b_финальная_валидация_согласованности/merged_with_tags.json b/2b_финальная_валидация_согласованности/merged_with_tags.json deleted file mode 100644 index 4cff75d..0000000 --- a/2b_финальная_валидация_согласованности/merged_with_tags.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "framework": { - "terms": { - "стимул": "что в окружающей среде заставляет человека действовать", - "привычка": "повторяющееся действие, которое становится автоматическим", - "подкрепление": "воздействие, усиливающее вероятность повторения действия", - "среда": "окружение, влияющее на поведение через стимулы и ограничения", - "желание, вызванное стимулом": "непроизвольная тяга к действию, спровоцированному внешним триггером", - "аутокаталитический процесс": "процесс, усиливающий себя сам" - }, - "principles": [ - { - "title": "Среда формирует поведение", - "description": "Человек не управляет привычками напрямую, а воздействует на окружение, чтобы оно вело его к нужным действиям", - "example": "Убрать телефон с рабочего стола, чтобы не отвлекаться на соцсети", - "chains": [ - { - "cause": "Человек находится в среде с доступными стимулами для нежелательной привычки", - "mechanism": "Стимул активирует уже сформированную привычку, которая запускает автоматическое действие", - "result": "Человек повторяет вредную привычку, не осознавая этого" - }, - { - "cause": "Изменяется среда, удаляются стимулы, провоцирующие нежелательную привычку", - "mechanism": "Отсутствие триггеров снижает вероятность срабатывания привычки", - "result": "Человек реже проявляет нежелательное поведение" - } - ] - }, - { - "title": "Привычки не исчезают, они только временно подавлены", - "description": "Даже если человек перестаёт делать что-то, привычка остаётся в мозге и может вернуться при подходящих условиях", - "example": "Человек перестаёт курить, но снова начинает после посещения места, где раньше курил", - "chains": [ - { - "cause": "Человек прекращает проявлять вредную привычку", - "mechanism": "Мозг сохраняет нейронные связи, связанные с этой привычкой", - "result": "Привычка может вернуться, если появятся подходящие стимулы" - }, - { - "cause": "Окружение меняется, и в нем появляются триггеры, связанные с прошлой привычкой", - "mechanism": "Стимулы запускают автоматические паттерны поведения", - "result": "Человек снова начинает делать то, что уже перестал" - } - ] - }, - { - "title": "Самоконтроль — это краткосрочный, а не долгосрочный инструмент", - "description": "Противостояние искушению неэффективно на длительном сроке, нужно менять контекст", - "example": "Человек отказывается от сладкого, но возвращается к нему после стрессового дня", - "chains": [ - { - "cause": "Человек пытается контролировать себя в момент искушения", - "mechanism": "Это требует энергии и усилий, которые невозможно поддерживать всегда", - "result": "Человек устаёт от борьбы и возвращается к нежелательной привычке" - }, - { - "cause": "Человек изменяет окружение, чтобы искушения стали менее доступными", - "mechanism": "Стимулы для плохого поведения становятся незаметными или невозможными", - "result": "Человек реже сталкивается с искушением" - } - ] - }, - { - "title": "Дисциплина — это настройка среды, а не внутреннее усилие", - "description": "Сильные люди не просто дисциплинированы, а живут в среде, которая поддерживает их цели", - "example": "Человек встаёт раньше, потому что спальня тёмная и не имеет гаджетов", - "chains": [ - { - "cause": "Человек пытается быть дисциплинированным только силой воли", - "mechanism": "Это требует большого количества энергии и часто неудачно", - "result": "Человек теряет мотивацию и возвращается к нежелательному поведению" - }, - { - "cause": "Человек настраивает окружение так, чтобы хорошее поведение было лёгким", - "mechanism": "Стимулы для правильных действий становятся более доступными", - "result": "Человек чаще выбирает здоровые привычки" - } - ] - }, - { - "title": "Негативные эмоции усиливают негативное поведение", - "description": "Стресс, тревога и другие негативные чувства провоцируют возвращение к вредным привычкам", - "example": "Человек садится на диван и ест шоколад, когда ему плохо", - "chains": [ - { - "cause": "Человек испытывает стресс или негативные эмоции", - "mechanism": "Это активирует привычку, которая помогает снизить дискомфорт", - "result": "Человек снова начинает делать то, что раньше снимало напряжение" - }, - { - "cause": "Человек пытается избавиться от вредной привычки, но не меняет среду", - "mechanism": "Стресс и негативные эмоции остаются, усиливая желание к действию", - "result": "Человек возвращается к нежелательной привычке" - } - ] - } - ] - }, - "insights": [ - { - "title": "Среда влияет на привычки больше, чем сила воли", - "description": "Чтобы изменить привычку, нужно изменить окружение, а не надеяться только на самоконтроль", - "example": "Убрать телефон с рабочего стола, чтобы не отвлекаться на соцсети" - }, - { - "title": "Привычки не исчезают, только временно подавляются", - "description": "Даже если человек перестаёт курить или есть сладкое, эти привычки остаются в мозге и могут вернуться при подходящих условиях", - "example": "После посещения бара человек снова начинает пить, не осознавая этого" - }, - { - "title": "Негативные эмоции усиливают вредное поведение", - "description": "Стресс, тревога и другие негативные чувства могут спровоцировать возвращение к старым привычкам", - "example": "После ссоры человек снова начинает есть вредную пищу, чтобы успокоиться" - }, - { - "title": "Сокращение стимулов уменьшает повторение нежелательного поведения", - "description": "Удаление триггеров, связанных с вредной привычкой, снижает вероятность её возобновления", - "example": "Убрать сигареты из дома, чтобы уменьшить вероятность их употребления" - }, - { - "title": "Самоконтроль — это временная стратегия, а не долгосрочное решение", - "description": "Постоянно бороться с искушением утомительно и непрочное; нужно настраивать среду так, чтобы искушений не было совсем", - "example": "Поставить телефон в другую комнату, чтобы не проверять соцсети во время работы" - } - ], - "application": { - "techniques": [ - { - "name": "Снижение доступности стимула", - "goal": "Уменьшение вероятности повторения нежелательной привычки за счёт удаления её триггеров", - "context_example": "Вечер после работы, когда тянет залипнуть в телефон и проверять соцсети", - "steps": [ - "Определите конкретный стимул (например, наличие телефона на столе)", - "Уберите объект из зоны видимости или досягаемости (например, оставьте телефон в другой комнате)", - "Запишите дату и время изменения (для отслеживания эффективности)" - ], - "client_phrase": "Уберите источник искушения из вашей среды — действие станет менее вероятным", - "success_criteria": "Частота использования телефона в моменты, когда это не нужно, снижается с 5 раз/день до 1–2 раз/день за 2 недели" - } - ] - }, - "limitations": [ - { - "description": "Идеи не работают, если триггер (то, что в окружении запускает действие) не устранён или остаётся доступным, так как привычка может вернуться при появлении стимула", - "when_relevant": "Когда человек не полностью убирает триггер из своей среды или сталкивается с ним в новой обстановке", - "example": "Человек удалил сигареты из дома, но снова начинает курить на работе среди коллег-курильщиков" - }, - { - "description": "Техники могут не сработать, если негативные эмоции (стресс, тревога) остаются необратленными и усиливают нежелательное поведение", - "when_relevant": "Когда человек продолжает испытывать сильный стресс или эмоциональные трудности, не решая их на уровне среды", - "example": "Человек убрал телефон из комнаты, но всё равно переедает перед сном из-за хронической тревоги" - }, - { - "description": "Изменения среды могут быть недостаточны, если привычка закреплена на уровне автоматических нейронных паттернов и не сопровождается новыми, здоровыми привычками", - "when_relevant": "Когда человек устраняет триггер, но не вводит альтернативное поведение для подкрепления", - "example": "Человек убрал телевизор из спальни, но не нашёл замену в виде чтения или медитации и проводит время впустую" - } - ], - "tags": { - "principle": [ - { - "tag": "среда формирует поведение", - "confidence": 0.95 - }, - { - "tag": "привычки не исчезают", - "confidence": 0.92 - }, - { - "tag": "самоконтроль краткосрочен", - "confidence": 0.9 - }, - { - "tag": "дисциплина через среду", - "confidence": 0.9 - }, - { - "tag": "негативные эмоции усиливают поведение", - "confidence": 0.85 - } - ], - "psychology": [ - { - "tag": "стимул", - "confidence": 0.95 - }, - { - "tag": "привычка", - "confidence": 0.95 - }, - { - "tag": "желание вызванное стимулом", - "confidence": 0.9 - }, - { - "tag": "аутокаталитический процесс", - "confidence": 0.85 - }, - { - "tag": "подкрепление", - "confidence": 0.8 - } - ], - "method": [ - { - "tag": "снижение доступности стимула", - "confidence": 0.95 - }, - { - "tag": "настройка среды", - "confidence": 0.92 - }, - { - "tag": "устранение триггеров", - "confidence": 0.9 - } - ], - "result": [ - { - "tag": "уменьшение вредных привычек", - "confidence": 0.95 - }, - { - "tag": "устойчивое поведение", - "confidence": 0.9 - }, - { - "tag": "снижение искушений", - "confidence": 0.85 - } - ], - "context": [ - { - "tag": "поведенческая психология", - "confidence": 0.95 - }, - { - "tag": "привычки", - "confidence": 0.92 - }, - { - "tag": "атомные привычки", - "confidence": 0.9 - } - ] - }, - "removed": [] -} \ No newline at end of file diff --git a/2b_финальная_валидация_согласованности/run_consistency_ollama.py b/2b_финальная_валидация_согласованности/run_consistency_ollama.py new file mode 100644 index 0000000..8b525a9 --- /dev/null +++ b/2b_финальная_валидация_согласованности/run_consistency_ollama.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Финальная валидация согласованности анализа главы через Ollama (шаг 2b). +Проверяет связи между блоками: application ↔ framework, insights ↔ framework, limitations ↔ остальное. +Вход: merge.json (полный анализ), вход_главы.json (метаданные главы). +Выход: один JSON-файл (verdict, score, inconsistencies). +""" + +import argparse +import json +import re +import sys +import time +import urllib.request +from pathlib import Path + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen3:14b" +DIR = Path(__file__).resolve().parent +DEFAULT_MERGE = DIR.parent / "1_анализ_главы" / "merge.json" +DEFAULT_CHAPTER = DIR.parent / "1_анализ_главы" / "вход_главы.json" + +OLLAMA_OPTIONS = { + "temperature": 0.2, + "num_ctx": 8500, + "num_predict": 2048, + "repeat_penalty": 1.1, +} + +PROMPT_FILE = "validate_consistency.txt" + + +def load_json(path: Path) -> dict: + """Загружает JSON из файла.""" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def load_prompt(filename: str) -> str: + """Загружает шаблон промпта из файла.""" + with open(DIR / filename, encoding="utf-8") as f: + return f.read() + + +def substitute_prompt( + prompt: str, + book_title: str, + chapter_title: str, + full_analysis_json: str, +) -> str: + """Подставляет в промпт поля главы и полный JSON анализа.""" + return ( + prompt.replace("{book_title}", book_title) + .replace("{chapter_title}", chapter_title) + .replace("{full_analysis_json}", full_analysis_json) + ) + + +def extract_json_from_response(text: str) -> dict: + """Достаёт JSON из ответа модели (может быть обёрнут в ```json ... ```).""" + text = text.strip() + match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) + if match: + text = match.group(1).strip() + return json.loads(text) + + +def call_ollama(prompt: str) -> str: + """Вызывает Ollama /api/chat и возвращает content ответа.""" + body = json.dumps( + { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "format": "json", + "options": OLLAMA_OPTIONS, + "keep_alive": 0, + }, + ensure_ascii=False, + ).encode("utf-8") + req = urllib.request.Request( + f"{OLLAMA_URL}/api/chat", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=None) as resp: + data = json.load(resp) + return data.get("message", {}).get("content", "") + except urllib.error.HTTPError as e: + body_b = b"" + if e.fp: + try: + body_b = e.fp.read()[:1000] + except Exception: + pass + raise RuntimeError( + f"Ollama HTTP {e.code}: {e.reason}. Body: {body_b.decode('utf-8', errors='replace')}" + ) from e + + +def main() -> int: + """Загружает данные, вызывает валидатор согласованности, пишет результат в JSON.""" + parser = argparse.ArgumentParser( + description="Финальная валидация согласованности анализа главы через Ollama (шаг 2b)." + ) + parser.add_argument( + "--merge", + type=Path, + default=DEFAULT_MERGE, + help=f"Путь к merge.json (по умолчанию: {DEFAULT_MERGE})", + ) + parser.add_argument( + "--chapter", + type=Path, + default=DEFAULT_CHAPTER, + help=f"Путь к вход_главы.json (по умолчанию: {DEFAULT_CHAPTER})", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DIR / "consistency_result.json", + help="Путь к выходному JSON (по умолчанию: consistency_result.json)", + ) + args = parser.parse_args() + + if not args.merge.is_file(): + print(f"Файл не найден: {args.merge}", file=sys.stderr) + return 1 + if not args.chapter.is_file(): + print(f"Файл не найден: {args.chapter}", file=sys.stderr) + return 1 + + print("Загрузка merge.json и вход_главы.json...") + merge = load_json(args.merge) + chapter = load_json(args.chapter) + book_title = chapter.get("book_title", "") + chapter_title = chapter.get("chapter_title", "") + full_analysis_json = json.dumps(merge, ensure_ascii=False, indent=2) + + prompt_tpl = load_prompt(PROMPT_FILE) + prompt = substitute_prompt( + prompt_tpl, + book_title, + chapter_title, + full_analysis_json, + ) + + print(f"Вызов Ollama {MODEL} — валидация согласованности...") + t0 = time.monotonic() + try: + raw = call_ollama(prompt) + except Exception as e: + print(f"Ошибка вызова Ollama: {e}", file=sys.stderr) + return 1 + elapsed = time.monotonic() - t0 + print(f"Ответ получен за {elapsed:.1f} сек ({elapsed / 60:.1f} мин)") + + try: + result = extract_json_from_response(raw) + except json.JSONDecodeError as e: + print(f"Не удалось распарсить JSON ответа: {e}", file=sys.stderr) + print("Первые 500 символов ответа:", raw[:500], file=sys.stderr) + return 1 + + if not isinstance(result, dict): + print( + f"Ожидался объект JSON, получен: {type(result).__name__}", + file=sys.stderr, + ) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"Записано: {args.output}") + verdict = result.get("verdict", "?") + print(f"Вердикт: {verdict}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/3_извлечение_тегов/README.md b/3_извлечение_тегов/README.md index d092f11..4e4749a 100644 --- a/3_извлечение_тегов/README.md +++ b/3_извлечение_тегов/README.md @@ -27,6 +27,29 @@ JSON с тегами по категориям (ключи на английском: `principle`, `psychology`, `method`, `result`, `context`) и confidence score для каждого тега; при необходимости — кандидаты в `proposed` с полем `category` из того же набора. Маппинг категорий: ARCHITECTURE_SUMMARY.md → раздел «Хранение данных» → таблица `tags`. -## Использование +## Скрипт через Ollama -Вызывается после шага 2b (финальная валидация). Модель: qwen3-14b:8bit (или аналог). Перед вызовом в промпт подставляется актуальный список тегов из БД. +`run_extract_tags_ollama.py` — один вызов Ollama для извлечения тегов по категориям. На выходе один JSON-файл. + +**Вход (по умолчанию):** +- `../1_анализ_главы/merge.json` — полный анализ (framework, insights, application, limitations) +- `../1_анализ_главы/вход_главы.json` — текст главы и метаданные +- `allowed_tags.json` — допустимые теги по категориям (объект с ключами principle, psychology, method, result, context и массивами строк). Если файла нет — используется пустой список (модель вернёт только блок `proposed`). Пример: `allowed_tags.example.json`. + +**Выход:** `extracted_tags.json` в каталоге скрипта (или путь через `-o`). + +**Формат выхода:** `tags` (по категориям: principle, psychology, method, result, context — массивы объектов `{ "tag", "confidence" }`), `proposed` (кандидаты в новые теги). + +**Запуск:** +```bash +cd 3_извлечение_тегов +# подготовить список тегов (скопировать пример или экспорт из БД): +cp allowed_tags.example.json allowed_tags.json +python3 run_extract_tags_ollama.py +# с указанием путей: +python3 run_extract_tags_ollama.py --merge /path/to/merge.json --chapter /path/to/вход_главы.json --allowed-tags allowed_tags.json -o extracted_tags.json +``` + +## Использование в пайплайне + +Вызывается после шага 2b (финальная валидация). Модель: qwen3-14b:8bit (или аналог). Перед вызовом в промпт подставляется актуальный список тегов из БД (или из файла `allowed_tags.json`). diff --git a/3_извлечение_тегов/allowed_tags.example.json b/3_извлечение_тегов/allowed_tags.example.json new file mode 100644 index 0000000..7dd77dd --- /dev/null +++ b/3_извлечение_тегов/allowed_tags.example.json @@ -0,0 +1,31 @@ +{ + "principle": [ + "среда формирует поведение", + "привычки не исчезают", + "самоконтроль краткосрочен", + "дисциплина через среду", + "негативные эмоции усиливают поведение" + ], + "psychology": [ + "стимул", + "привычка", + "желание вызванное стимулом", + "аутокаталитический процесс", + "подкрепление" + ], + "method": [ + "снижение доступности стимула", + "настройка среды", + "устранение триггеров" + ], + "result": [ + "уменьшение вредных привычек", + "устойчивое поведение", + "снижение искушений" + ], + "context": [ + "поведенческая психология", + "привычки", + "атомные привычки" + ] +} diff --git a/3_извлечение_тегов/run_extract_tags_ollama.py b/3_извлечение_тегов/run_extract_tags_ollama.py new file mode 100644 index 0000000..316faae --- /dev/null +++ b/3_извлечение_тегов/run_extract_tags_ollama.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Извлечение тегов по категориям из анализа главы через Ollama (шаг 3). +Вход: merge.json (анализ), вход_главы.json (текст главы), allowed_tags.json (допустимые теги). +Выход: один JSON-файл (tags по категориям, proposed). +""" + +import argparse +import json +import re +import sys +import time +import urllib.request +from pathlib import Path + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen3:14b" +DIR = Path(__file__).resolve().parent +DEFAULT_MERGE = DIR.parent / "1_анализ_главы" / "merge.json" +DEFAULT_CHAPTER = DIR.parent / "1_анализ_главы" / "вход_главы.json" +DEFAULT_ALLOWED_TAGS = DIR / "allowed_tags.json" + +OLLAMA_OPTIONS = { + "temperature": 0.3, + "num_ctx": 8500, + "num_predict": 2048, + "repeat_penalty": 1.1, +} + +PROMPT_FILE = "extract_tags.txt" + + +def load_json(path: Path) -> dict: + """Загружает JSON из файла.""" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def load_prompt(filename: str) -> str: + """Загружает шаблон промпта из файла.""" + with open(DIR / filename, encoding="utf-8") as f: + return f.read() + + +def substitute_prompt( + prompt: str, + book_title: str, + chapter_title: str, + framework_str: str, + insights_str: str, + application_str: str, + limitations_str: str, + chapter_text: str, + allowed_tags_str: str, +) -> str: + """Подставляет в промпт все поля.""" + return ( + prompt.replace("{book_title}", book_title) + .replace("{chapter_title}", chapter_title) + .replace("{framework}", framework_str) + .replace("{insights}", insights_str) + .replace("{application}", application_str) + .replace("{limitations}", limitations_str) + .replace("{chapter_text}", chapter_text) + .replace("{allowed_tags_json}", allowed_tags_str) + ) + + +def extract_json_from_response(text: str) -> dict: + """Достаёт JSON из ответа модели (может быть обёрнут в ```json ... ```).""" + text = text.strip() + match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) + if match: + text = match.group(1).strip() + return json.loads(text) + + +def call_ollama(prompt: str) -> str: + """Вызывает Ollama /api/chat и возвращает content ответа.""" + body = json.dumps( + { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "format": "json", + "options": OLLAMA_OPTIONS, + "keep_alive": 0, + }, + ensure_ascii=False, + ).encode("utf-8") + req = urllib.request.Request( + f"{OLLAMA_URL}/api/chat", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=None) as resp: + data = json.load(resp) + return data.get("message", {}).get("content", "") + except urllib.error.HTTPError as e: + body_b = b"" + if e.fp: + try: + body_b = e.fp.read()[:1000] + except Exception: + pass + raise RuntimeError( + f"Ollama HTTP {e.code}: {e.reason}. Body: {body_b.decode('utf-8', errors='replace')}" + ) from e + + +def main() -> int: + """Загружает данные, вызывает извлечение тегов, пишет результат в JSON.""" + parser = argparse.ArgumentParser( + description="Извлечение тегов по категориям из анализа главы через Ollama (шаг 3)." + ) + parser.add_argument( + "--merge", + type=Path, + default=DEFAULT_MERGE, + help=f"Путь к merge.json (по умолчанию: {DEFAULT_MERGE})", + ) + parser.add_argument( + "--chapter", + type=Path, + default=DEFAULT_CHAPTER, + help=f"Путь к вход_главы.json (по умолчанию: {DEFAULT_CHAPTER})", + ) + parser.add_argument( + "--allowed-tags", + type=Path, + default=DEFAULT_ALLOWED_TAGS, + help=f"Путь к JSON со списком допустимых тегов по категориям (по умолчанию: allowed_tags.json). Формат: объект с ключами principle, psychology, method, result, context и массивами строк.", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DIR / "extracted_tags.json", + help="Путь к выходному JSON (по умолчанию: extracted_tags.json)", + ) + args = parser.parse_args() + + if not args.merge.is_file(): + print(f"Файл не найден: {args.merge}", file=sys.stderr) + return 1 + if not args.chapter.is_file(): + print(f"Файл не найден: {args.chapter}", file=sys.stderr) + return 1 + + print("Загрузка merge.json, вход_главы.json и allowed_tags...") + merge = load_json(args.merge) + chapter = load_json(args.chapter) + + if args.allowed_tags.is_file(): + allowed_tags = load_json(args.allowed_tags) + else: + allowed_tags = {} + print( + f"Файл {args.allowed_tags} не найден; используется пустой список тегов (модель вернёт только proposed).", + file=sys.stderr, + ) + + book_title = chapter.get("book_title", "") + chapter_title = chapter.get("chapter_title", "") + chapter_text = chapter.get("chapter_text", "") + + framework_str = json.dumps(merge.get("framework", {}), ensure_ascii=False, indent=2) + insights_str = json.dumps(merge.get("insights", []), ensure_ascii=False, indent=2) + application_str = json.dumps( + merge.get("application", {}), ensure_ascii=False, indent=2 + ) + limitations_str = json.dumps( + merge.get("limitations", []), ensure_ascii=False, indent=2 + ) + allowed_tags_str = json.dumps(allowed_tags, ensure_ascii=False, indent=2) + + prompt_tpl = load_prompt(PROMPT_FILE) + prompt = substitute_prompt( + prompt_tpl, + book_title, + chapter_title, + framework_str, + insights_str, + application_str, + limitations_str, + chapter_text, + allowed_tags_str, + ) + + print(f"Вызов Ollama {MODEL} — извлечение тегов...") + t0 = time.monotonic() + try: + raw = call_ollama(prompt) + except Exception as e: + print(f"Ошибка вызова Ollama: {e}", file=sys.stderr) + return 1 + elapsed = time.monotonic() - t0 + print(f"Ответ получен за {elapsed:.1f} сек ({elapsed / 60:.1f} мин)") + + try: + result = extract_json_from_response(raw) + except json.JSONDecodeError as e: + print(f"Не удалось распарсить JSON ответа: {e}", file=sys.stderr) + print("Первые 500 символов ответа:", raw[:500], file=sys.stderr) + return 1 + + if not isinstance(result, dict): + print( + f"Ожидался объект JSON, получен: {type(result).__name__}", + file=sys.stderr, + ) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"Записано: {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/4_валидация_тегов/README.md b/4_валидация_тегов/README.md index 3cc6321..2782487 100644 --- a/4_валидация_тегов/README.md +++ b/4_валидация_тегов/README.md @@ -26,6 +26,27 @@ JSON: проверенные теги по категориям с обновлёнными confidence; теги, снятые при валидации, — в блоке `removed` (tag, category, reason). Формат `tags` совпадает с выходом шага 3 для передачи в эмбеддинг и сохранение в БД. -## Использование +## Скрипт через Ollama + +`run_validate_tags_ollama.py` — один вызов Ollama для валидации извлечённых тегов. На выходе один JSON-файл. + +**Вход (по умолчанию):** +- `../3_извлечение_тегов/extracted_tags.json` — результат шага 3 (tags по категориям, proposed) +- `../1_анализ_главы/merge.json` — полный анализ (framework, insights, application) +- `../1_анализ_главы/вход_главы.json` — текст главы и метаданные + +**Выход:** `validated_tags.json` в каталоге скрипта (или путь через `-o`). + +**Формат выхода:** `tags` (по категориям с обновлённым confidence), `removed` (массив снятых тегов: tag, category, reason). + +**Запуск:** +```bash +cd 4_валидация_тегов +python3 run_validate_tags_ollama.py +# с указанием путей: +python3 run_validate_tags_ollama.py --extracted-tags /path/to/extracted_tags.json --merge /path/to/merge.json --chapter /path/to/вход_главы.json -o validated_tags.json +``` + +## Использование в пайплайне Вызывается после шага 3 (извлечение тегов). Модель: qwen3-14b:8bit (или аналог). Время: ~20–30 сек на главу. diff --git a/4_валидация_тегов/run_validate_tags_ollama.py b/4_валидация_тегов/run_validate_tags_ollama.py new file mode 100644 index 0000000..46c4d0c --- /dev/null +++ b/4_валидация_тегов/run_validate_tags_ollama.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +Валидация извлечённых тегов через Ollama (шаг 4). +Проверка релевантности тегов содержанию главы и анализу; снятие нерелевантных, обновление confidence. +Вход: extracted_tags.json (шаг 3), merge.json (анализ), вход_главы.json (текст главы). +Выход: один JSON-файл (tags с обновлённым confidence, removed). +""" + +import argparse +import json +import re +import sys +import time +import urllib.request +from pathlib import Path + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen3:14b" +DIR = Path(__file__).resolve().parent +DEFAULT_EXTRACTED_TAGS = DIR.parent / "3_извлечение_тегов" / "extracted_tags.json" +DEFAULT_MERGE = DIR.parent / "1_анализ_главы" / "merge.json" +DEFAULT_CHAPTER = DIR.parent / "1_анализ_главы" / "вход_главы.json" + +OLLAMA_OPTIONS = { + "temperature": 0.2, + "num_ctx": 8500, + "num_predict": 2048, + "repeat_penalty": 1.1, +} + +PROMPT_FILE = "validate_tags.txt" + + +def load_json(path: Path) -> dict: + """Загружает JSON из файла.""" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def load_prompt(filename: str) -> str: + """Загружает шаблон промпта из файла.""" + with open(DIR / filename, encoding="utf-8") as f: + return f.read() + + +def substitute_prompt( + prompt: str, + book_title: str, + chapter_title: str, + extracted_tags_str: str, + framework_str: str, + insights_str: str, + application_str: str, + chapter_text: str, +) -> str: + """Подставляет в промпт все поля.""" + return ( + prompt.replace("{book_title}", book_title) + .replace("{chapter_title}", chapter_title) + .replace("{extracted_tags_json}", extracted_tags_str) + .replace("{framework}", framework_str) + .replace("{insights}", insights_str) + .replace("{application}", application_str) + .replace("{chapter_text}", chapter_text) + ) + + +def extract_json_from_response(text: str) -> dict: + """Достаёт JSON из ответа модели (может быть обёрнут в ```json ... ```).""" + text = text.strip() + match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) + if match: + text = match.group(1).strip() + return json.loads(text) + + +def call_ollama(prompt: str) -> str: + """Вызывает Ollama /api/chat и возвращает content ответа.""" + body = json.dumps( + { + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "format": "json", + "options": OLLAMA_OPTIONS, + "keep_alive": 0, + }, + ensure_ascii=False, + ).encode("utf-8") + req = urllib.request.Request( + f"{OLLAMA_URL}/api/chat", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=None) as resp: + data = json.load(resp) + return data.get("message", {}).get("content", "") + except urllib.error.HTTPError as e: + body_b = b"" + if e.fp: + try: + body_b = e.fp.read()[:1000] + except Exception: + pass + raise RuntimeError( + f"Ollama HTTP {e.code}: {e.reason}. Body: {body_b.decode('utf-8', errors='replace')}" + ) from e + + +def main() -> int: + """Загружает данные, вызывает валидацию тегов, пишет результат в JSON.""" + parser = argparse.ArgumentParser( + description="Валидация извлечённых тегов через Ollama (шаг 4)." + ) + parser.add_argument( + "--extracted-tags", + type=Path, + default=DEFAULT_EXTRACTED_TAGS, + help=f"Путь к extracted_tags.json из шага 3 (по умолчанию: {DEFAULT_EXTRACTED_TAGS})", + ) + parser.add_argument( + "--merge", + type=Path, + default=DEFAULT_MERGE, + help=f"Путь к merge.json (по умолчанию: {DEFAULT_MERGE})", + ) + parser.add_argument( + "--chapter", + type=Path, + default=DEFAULT_CHAPTER, + help=f"Путь к вход_главы.json (по умолчанию: {DEFAULT_CHAPTER})", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DIR / "validated_tags.json", + help="Путь к выходному JSON (по умолчанию: validated_tags.json)", + ) + args = parser.parse_args() + + if not args.extracted_tags.is_file(): + print(f"Файл не найден: {args.extracted_tags}", file=sys.stderr) + return 1 + if not args.merge.is_file(): + print(f"Файл не найден: {args.merge}", file=sys.stderr) + return 1 + if not args.chapter.is_file(): + print(f"Файл не найден: {args.chapter}", file=sys.stderr) + return 1 + + print("Загрузка extracted_tags.json, merge.json и вход_главы.json...") + extracted = load_json(args.extracted_tags) + merge = load_json(args.merge) + chapter = load_json(args.chapter) + + book_title = chapter.get("book_title", "") + chapter_title = chapter.get("chapter_title", "") + chapter_text = chapter.get("chapter_text", "") + + extracted_tags_str = json.dumps(extracted, ensure_ascii=False, indent=2) + framework_str = json.dumps(merge.get("framework", {}), ensure_ascii=False, indent=2) + insights_str = json.dumps(merge.get("insights", []), ensure_ascii=False, indent=2) + application_str = json.dumps( + merge.get("application", {}), ensure_ascii=False, indent=2 + ) + + prompt_tpl = load_prompt(PROMPT_FILE) + prompt = substitute_prompt( + prompt_tpl, + book_title, + chapter_title, + extracted_tags_str, + framework_str, + insights_str, + application_str, + chapter_text, + ) + + print(f"Вызов Ollama {MODEL} — валидация тегов...") + t0 = time.monotonic() + try: + raw = call_ollama(prompt) + except Exception as e: + print(f"Ошибка вызова Ollama: {e}", file=sys.stderr) + return 1 + elapsed = time.monotonic() - t0 + print(f"Ответ получен за {elapsed:.1f} сек ({elapsed / 60:.1f} мин)") + + try: + result = extract_json_from_response(raw) + except json.JSONDecodeError as e: + print(f"Не удалось распарсить JSON ответа: {e}", file=sys.stderr) + print("Первые 500 символов ответа:", raw[:500], file=sys.stderr) + return 1 + + if not isinstance(result, dict): + print( + f"Ожидался объект JSON, получен: {type(result).__name__}", + file=sys.stderr, + ) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"Записано: {args.output}") + removed_count = len(result.get("removed", [])) + print(f"Снято тегов: {removed_count}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/5_мерж_анализа_и_тегов/README.md b/5_мерж_анализа_и_тегов/README.md index 353fd88..30123d6 100644 --- a/5_мерж_анализа_и_тегов/README.md +++ b/5_мерж_анализа_и_тегов/README.md @@ -14,16 +14,37 @@ Один JSON: все поля анализа + `tags`, `removed`. Если задан `--input-chapter`, в выход добавляются метаданные главы/книги (book_id, chapter_id, chapter_number, chapter_title, book_title, author). Этот документ передаётся в шаг 6 (генерация эмбеддингов) и в шаг 7 (payload Qdrant). -## Использование +## Скрипт с путями по умолчанию +`run_merge_analysis_tags.py` — мерж с путями по умолчанию (как в остальных этапах). Без вызова LLM. + +**Вход (по умолчанию):** +- `../1_анализ_главы/merge.json` — полный анализ (framework, insights, application, limitations) +- `../4_валидация_тегов/validated_tags.json` — результат шага 4 (tags, removed) +- `../1_анализ_главы/вход_главы.json` — метаданные главы/книги (book_id, chapter_id и т.д.; подставляются в выход для шагов 6–8) + +**Выход:** `merged_with_tags.json` в каталоге скрипта (или путь через `-o`). + +**Запуск:** ```bash -python3 merge_analysis_tags.py <выход_valid_tag.json> [--input-chapter вход_главы.json] [-o выход.json] +cd 5_мерж_анализа_и_тегов +python3 run_merge_analysis_tags.py +# с указанием путей: +python3 run_merge_analysis_tags.py --merge /path/to/merge.json --tags /path/to/validated_tags.json --input-chapter /path/to/вход_главы.json -o merged_with_tags.json +# без метаданных главы: +python3 run_merge_analysis_tags.py --no-chapter ``` -Пример (с метаданными для шага 7): +## Универсальный скрипт (позиционные аргументы) ```bash -python3 merge_analysis_tags.py ../2b_финальная_валидация_согласованности/merge.json ../4_валидация_тегов/выход_valid_tag.json --input-chapter ../1_анализ_главы/вход_главы.example.json -o merged_with_tags.json +python3 merge_analysis_tags.py [--input-chapter вход_главы.json] [-o выход.json] +``` + +Пример: + +```bash +python3 merge_analysis_tags.py ../1_анализ_главы/merge.json ../4_валидация_тегов/validated_tags.json --input-chapter ../1_анализ_главы/вход_главы.json -o merged_with_tags.json ``` Без `-o` результат выводится в stdout. diff --git a/5_мерж_анализа_и_тегов/run_merge_analysis_tags.py b/5_мерж_анализа_и_тегов/run_merge_analysis_tags.py new file mode 100644 index 0000000..572b36b --- /dev/null +++ b/5_мерж_анализа_и_тегов/run_merge_analysis_tags.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Запуск шага 5 с путями по умолчанию: мерж анализа (2b) и валидированных тегов (4). +Без вызова LLM — только слияние JSON. Использует merge_analysis_tags.merge_analysis_and_tags. +Вход по умолчанию: merge.json (1), validated_tags.json (4), вход_главы.json (1). +Выход по умолчанию: merged_with_tags.json в каталоге скрипта. +""" + +import argparse +import json +import sys +from pathlib import Path + +DIR = Path(__file__).resolve().parent +DEFAULT_ANALYSIS = DIR.parent / "1_анализ_главы" / "merge.json" +DEFAULT_TAGS = DIR.parent / "4_валидация_тегов" / "validated_tags.json" +DEFAULT_CHAPTER = DIR.parent / "1_анализ_главы" / "вход_главы.json" +DEFAULT_OUTPUT = DIR / "merged_with_tags.json" + +# Импорт логики мержа из основного скрипта +from merge_analysis_tags import load_json, merge_analysis_and_tags + + +def main() -> int: + """Загружает анализ и теги по путям (по умолчанию — из соседних этапов), мержит, пишет выход.""" + parser = argparse.ArgumentParser( + description="Мерж анализа (2b) и валидированных тегов (4) в один JSON (шаг 5). Пути по умолчанию — из этапов 1 и 4.", + ) + parser.add_argument( + "--merge", + type=Path, + default=DEFAULT_ANALYSIS, + help=f"Путь к merge.json с анализом (по умолчанию: {DEFAULT_ANALYSIS})", + ) + parser.add_argument( + "--tags", + type=Path, + default=DEFAULT_TAGS, + help=f"Путь к validated_tags.json (по умолчанию: {DEFAULT_TAGS})", + ) + parser.add_argument( + "--input-chapter", + type=Path, + default=DEFAULT_CHAPTER, + help=f"Путь к вход_главы.json для метаданных главы/книги (по умолчанию: {DEFAULT_CHAPTER})", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"Путь к выходному JSON (по умолчанию: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--no-chapter", + action="store_true", + help="Не подставлять метаданные главы из --input-chapter.", + ) + args = parser.parse_args() + + if not args.merge.is_file(): + print(f"Файл не найден: {args.merge}", file=sys.stderr) + return 1 + if not args.tags.is_file(): + print(f"Файл не найден: {args.tags}", file=sys.stderr) + return 1 + + print("Загрузка merge.json и validated_tags.json...") + try: + analysis = load_json(args.merge) + tags_doc = load_json(args.tags) + except json.JSONDecodeError as e: + print(f"Ошибка разбора JSON: {e}", file=sys.stderr) + return 1 + + input_chapter = None + if not args.no_chapter and args.input_chapter.is_file(): + try: + input_chapter = load_json(args.input_chapter) + except json.JSONDecodeError as e: + print(f"Ошибка разбора input-chapter JSON: {e}", file=sys.stderr) + return 1 + elif not args.no_chapter and not args.input_chapter.is_file(): + print(f"Файл не найден (метаданные главы не добавлены): {args.input_chapter}", file=sys.stderr) + + merged = merge_analysis_and_tags(analysis, tags_doc, input_chapter) + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(merged, f, ensure_ascii=False, indent=2) + print(f"Записано: {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/6_генерация_эмбеддингов/README.md b/6_генерация_эмбеддингов/README.md index bd7b17d..5ff9bbf 100644 --- a/6_генерация_эмбеддингов/README.md +++ b/6_генерация_эмбеддингов/README.md @@ -30,6 +30,29 @@ Ограничения и limitations в эмбеддинг по умолчанию не включаются (опционально — в конфиге). Теги — только в payload, не в тексте для эмбеддинга. -## Использование +## Скрипт через Ollama + +`run_embed_ollama.py` — один вызов Ollama `/api/embed` для генерации вектора по JSON шага 5. Текст для эмбеддинга собирается из framework, insights, application по `embed_input_spec.txt` (функция `merged_json_to_embed_text` из `embed_cli.py`). + +**Вход (по умолчанию):** +- `../5_мерж_анализа_и_тегов/merged_with_tags.json` — результат шага 5 (анализ + теги) + +**Выход:** `embedding.json` в каталоге скрипта — массив float (вектор размерности 1024 для bge-m3). + +**Запуск:** +```bash +cd 6_генерация_эмбеддингов +python3 run_embed_ollama.py +# с указанием путей: +python3 run_embed_ollama.py --merged /path/to/merged_with_tags.json -o embedding.json +# другая модель или URL Ollama: +python3 run_embed_ollama.py --model bge-m3 --ollama-url http://localhost:11434 +``` + +## Универсальный CLI (OpenAI-совместимый API) + +`embed_cli.py` — для LM Studio или другого OpenAI-совместимого API (позиционные аргументы: путь к JSON, имя модели, опционально `--base-url`, `-o`). + +## Использование в пайплайне Вызывается после шага 5 (мерж анализа и тегов). Модель задаётся конфигом (env/конфиг); смена модели не меняет формат хранения в Qdrant, при смене — пересчёт эмбеддингов по необходимости. diff --git a/6_генерация_эмбеддингов/run_embed_ollama.py b/6_генерация_эмбеддингов/run_embed_ollama.py new file mode 100644 index 0000000..8cc3a73 --- /dev/null +++ b/6_генерация_эмбеддингов/run_embed_ollama.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Генерация эмбеддинга по JSON шага 5 через Ollama (шаг 6). +Текст для эмбеддинга собирается из framework, insights, application по embed_input_spec.txt. +Вход по умолчанию: merged_with_tags.json (5). Выход по умолчанию: embedding.json (вектор). +""" + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +DIR = Path(__file__).resolve().parent +DEFAULT_MERGED = DIR.parent / "5_мерж_анализа_и_тегов" / "merged_with_tags.json" +DEFAULT_OUTPUT = DIR / "embedding.json" + +OLLAMA_URL = "http://localhost:11434" +EMBED_MODEL = "bge-m3" + + +def get_embedding_ollama(base_url: str, model: str, text: str) -> list[float]: + """ + Запрашивает эмбеддинг текста у Ollama API (POST /api/embed). + + Args: + base_url: Базовый URL Ollama (например http://localhost:11434). + model: Имя модели эмбеддингов (например bge-m3). + text: Текст для эмбеддинга. + + Returns: + Вектор эмбеддинга (список float). + + Raises: + urllib.error.HTTPError: При ошибке HTTP. + ValueError: Если в ответе нет ожидаемой структуры. + """ + url = f"{base_url.rstrip('/')}/api/embed" + payload = {"model": model, "input": text} + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=120) as resp: + data: dict[str, Any] = json.loads(resp.read().decode("utf-8")) + if "embeddings" not in data or not data["embeddings"]: + raise ValueError("В ответе Ollama нет поля embeddings") + embedding = data["embeddings"][0] + if not isinstance(embedding, list): + raise ValueError("embeddings[0] не является массивом") + return [float(x) for x in embedding] + + +def main() -> int: + """Собирает текст из merged JSON, вызывает Ollama /api/embed, пишет вектор в файл.""" + from embed_cli import merged_json_to_embed_text + + parser = argparse.ArgumentParser( + description="Эмбеддинг по JSON шага 5 через Ollama (шаг 6). На выход — вектор (JSON).", + ) + parser.add_argument( + "--merged", + type=Path, + default=DEFAULT_MERGED, + help=f"Путь к merged_with_tags.json (по умолчанию: {DEFAULT_MERGED})", + ) + parser.add_argument( + "--model", + default=EMBED_MODEL, + help=f"Модель эмбеддингов в Ollama (по умолчанию: {EMBED_MODEL})", + ) + parser.add_argument( + "--ollama-url", + default=OLLAMA_URL, + help=f"URL Ollama (по умолчанию: {OLLAMA_URL})", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"Путь к выходному JSON с вектором (по умолчанию: {DEFAULT_OUTPUT})", + ) + args = parser.parse_args() + + if not args.merged.is_file(): + print(f"Файл не найден: {args.merged}", file=sys.stderr) + return 1 + + print("Загрузка merged_with_tags.json...") + try: + with open(args.merged, encoding="utf-8") as f: + merged = json.load(f) + except json.JSONDecodeError as e: + print(f"Ошибка разбора JSON: {e}", file=sys.stderr) + return 1 + + text = merged_json_to_embed_text(merged) + if not text: + print( + "Ошибка: текст для эмбеддинга пуст (нет framework/insights/application).", + file=sys.stderr, + ) + return 1 + + print(f"Вызов Ollama {args.model} — генерация эмбеддинга...") + t0 = time.monotonic() + try: + vector = get_embedding_ollama(args.ollama_url, args.model, text) + except urllib.error.HTTPError as e: + print(f"Ошибка HTTP {e.code}: {e.reason}", file=sys.stderr) + if e.fp: + try: + body = e.fp.read().decode("utf-8") + print(body[:500], file=sys.stderr) + except Exception: + pass + return 1 + except urllib.error.URLError as e: + print(f"Ошибка запроса: {e.reason}", file=sys.stderr) + return 1 + except ValueError as e: + print(f"Ошибка: {e}", file=sys.stderr) + return 1 + elapsed = time.monotonic() - t0 + print(f"Эмбеддинг получен за {elapsed:.1f} сек, размерность {len(vector)}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(vector, f, ensure_ascii=False) + print(f"Записано: {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/7_сохранение_qdrant/README.md b/7_сохранение_qdrant/README.md index 42df66a..143913c 100644 --- a/7_сохранение_qdrant/README.md +++ b/7_сохранение_qdrant/README.md @@ -14,11 +14,17 @@ ## Использование +Вектор — JSON-массив чисел из шага 6: файл `embedding.json` (Ollama, `run_embed_ollama.py`) или `vector.json` / любой другой файл с массивом, либо stdin. + ```bash # Вектор из файла (merged должен содержать book_id, chapter_id, chapter_number, chapter_title из шага 5 с --input-chapter) +# Вариант 1: после run_embed_ollama.py (шаг 6 через Ollama) +python3 save_to_qdrant.py 5_мерж_анализа_и_тегов/merged_with_tags.json 6_генерация_эмбеддингов/embedding.json [--validation-score 0.95] + +# Вариант 2: вектор в любом файле (например vector.json) python3 save_to_qdrant.py merged_with_tags.json vector.json [--validation-score 0.95] -# Вектор из stdin (пайплайн с шагом 6) +# Вектор из stdin (пайплайн с шагом 6 через embed_cli.py) python3 6_генерация_эмбеддингов/embed_cli.py merged_with_tags.json model_name \ | python3 7_сохранение_qdrant/save_to_qdrant.py merged_with_tags.json - ``` diff --git a/check_postgres_schema.py b/check_postgres_schema.py new file mode 100644 index 0000000..7f393eb --- /dev/null +++ b/check_postgres_schema.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Проверка наличия таблиц схемы (books, chapters, chapter_analyses, tags, chapter_tags) в Postgres.""" +import sys + +try: + import psycopg2 +except ImportError: + print("Установите: pip install psycopg2-binary") + sys.exit(1) + +HOST = "192.168.88.15" +PORT = 5432 +USER = "n8n" +PASSWORD = "n8n_password" +DB = "n8n" +EXPECTED = {"books", "chapters", "chapter_analyses", "tags", "chapter_tags"} + +try: + conn = psycopg2.connect( + host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DB + ) + with conn.cursor() as cur: + cur.execute(""" + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name; + """) + found = {row[0] for row in cur.fetchall()} + conn.close() + print(f"Postgres {HOST}:{PORT}/{DB}") + print("Таблицы в public:", ", ".join(sorted(found)) if found else "(нет)") + missing = EXPECTED - found + extra = found - EXPECTED + if not missing: + print("Ожидаемые таблицы этапа 8: все на месте.") + else: + print("Не найдены:", ", ".join(sorted(missing))) + if extra: + print("Доп. таблицы:", ", ".join(sorted(extra))) +except Exception as e: + print(f"Ошибка: {e}") + sys.exit(1)