Files
999/scripts/passport_auto_enricher.py
T

422 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
passport_auto_enricher.py
=========================
Автоматический обогатитель единого паспорта знака «Оракула 999».
Выполняет последовательный прогон 78 атомарных промптов на каждый символ (000–999).
Сохраняет состояние после каждого шага (checkpoint-per-field).
"""
import os
import sys
import json
import time
import argparse
import urllib.request
import urllib.error
from pathlib import Path
from typing import Dict, Any, List, Optional
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from oracle_enricher.enricher.passport_schema import (
PROMPTS_78,
PROMPT_BY_KEY,
PROMPT_BY_INDEX,
PassportPromptDef,
render_passport_markdown,
count_filled_fields,
empty_passport_record,
get_field_prompt,
get_all_78_prompts,
)
DB_PATH = PROJECT_ROOT / "assets" / "data" / "oracle_passports.json"
FALLBACK_DB_PATH = PROJECT_ROOT / "oracle_enricher" / "data" / "oracle_passports.json"
OUT_PASSPORT_DIR = PROJECT_ROOT / "assets" / "passports"
def load_database() -> Dict[str, Dict[str, Any]]:
path = DB_PATH if DB_PATH.exists() else FALLBACK_DB_PATH
if not path.exists():
print(f"[ERROR] Database file not found at {path}")
sys.exit(1)
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_database(data: Dict[str, Dict[str, Any]]) -> None:
# Atomic write to assets/data/oracle_passports.json
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
temp_path = DB_PATH.with_suffix(".tmp")
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(temp_path, DB_PATH)
# Sync to oracle_enricher/data if folder exists
if FALLBACK_DB_PATH.parent.exists():
try:
with open(FALLBACK_DB_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
pass
class LLMClient:
def __init__(
self,
backend: str = "dry-run",
model: str = "gpt-4o-mini",
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: int = 45,
):
self.backend = backend.lower()
self.model = model
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
self.base_url = (base_url or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
self.timeout = timeout
def generate(self, prompt: str, system_prompt: str = "") -> str:
if self.backend == "dry-run":
# Simulate or return dry run indication
return f"[DRY-RUN] {prompt[:60]}..."
elif self.backend == "mock":
# Return plausible mock responses
return "[Каноническое заполнение поля по заданному формату]"
elif self.backend in ("openai", "openrouter", "deepseek", "custom"):
return self._call_openai_compatible(prompt, system_prompt)
elif self.backend == "ollama":
return self._call_ollama(prompt, system_prompt)
else:
raise ValueError(f"Unknown backend: {self.backend}")
def _call_openai_compatible(self, prompt: str, system_prompt: str) -> str:
url = f"{self.base_url}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
payload = {
"model": self.model,
"messages": messages,
"temperature": 0.4,
"max_tokens": 500,
}
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
return body["choices"][0]["message"]["content"].strip()
def _call_ollama(self, prompt: str, system_prompt: str) -> str:
url = f"{self.base_url}/api/generate" if "11434" in self.base_url else "http://localhost:11434/api/generate"
payload = {
"model": self.model,
"prompt": prompt,
"system": system_prompt,
"stream": False,
"options": {"temperature": 0.4},
}
headers = {"Content-Type": "application/json"}
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
return body.get("response", "").strip()
def clean_llm_response(text: str, expected_key: str) -> str:
"""Clean extra fluff, quotes, or markdown code blocks from LLM output."""
t = text.strip()
if t.startswith("```") and t.endswith("```"):
lines = t.splitlines()
if len(lines) >= 3:
t = "\n".join(lines[1:-1]).strip()
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
t = t[1:-1].strip()
return t
def enrich_symbol(
entry: Dict[str, Any],
client: LLMClient,
db: Dict[str, Dict[str, Any]],
force: bool = False,
section_filter: Optional[str] = None,
field_filter: Optional[str] = None,
delay: float = 0.3,
max_retries: int = 3,
) -> int:
idx_num = entry.get("id", 0)
idx_str = f"{idx_num:03d}"
word = entry.get("word", "")
emoji = entry.get("emoji", "")
fields = entry.setdefault("fields", {})
print(f"\n======================================================================")
print(f"🔮 НАЧАЛО ОБОГАЩЕНИЯ: [{idx_str}] «{word.upper()}» {emoji}")
print(f" Заполнено: {entry.get('filled_count', 0)}/78 | Статус: {'ГОТОВО' if entry.get('is_complete') else 'В ПРОЦЕССЕ'}")
print(f"======================================================================")
enriched_this_run = 0
system_prompt = (
"Ты — сакральный историограф, лингвист, герменевт и архивариус системы «Оракул 999».\n"
"Твоя задача — формировать канонический единый паспорт знака строго по заданному формату.\n"
"Пиши предельно емко, глубоко, без лишних предисловий и вежливостей, строго соблюдая указанный шаблон."
)
for p_def in PROMPTS_78:
# Filter checks
if section_filter and p_def.section_num != section_filter:
continue
if field_filter and field_filter not in (p_def.key, str(p_def.index)):
continue
curr_val = fields.get(p_def.key, "")
if curr_val and not force:
continue
prompt_text = get_field_prompt(p_def.key, entry)
print(f" [{idx_str}|{word}] [{p_def.index:02d}/78] Разд.{p_def.section_num} • {p_def.title}...", end="", flush=True)
resp_text = ""
duration = 0.0
success = False
for attempt in range(max_retries):
try:
t0 = time.time()
raw_resp = client.generate(prompt_text, system_prompt=system_prompt)
resp_text = clean_llm_response(raw_resp, p_def.key)
duration = time.time() - t0
success = True
break
except Exception as e:
print(f" [Попытка {attempt+1} сбой: {e}]", end="", flush=True)
time.sleep(2.0 * (attempt + 1))
if not success:
print(" ❌ ОШИБКА")
continue
fields[p_def.key] = resp_text
filled, _ = count_filled_fields(entry)
entry["filled_count"] = filled
entry["is_complete"] = (filled >= 78)
entry["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# Real-time checkpoint save to disk!
if client.backend != "dry-run":
save_database(db)
enriched_this_run += 1
short_preview = (resp_text[:50] + "...") if len(resp_text) > 50 else resp_text
print(f" ✅ ({duration:.1f}s) -> {short_preview}")
if delay > 0:
time.sleep(delay)
# Export markdown if complete or substantial
if entry.get("filled_count", 0) >= 70:
OUT_PASSPORT_DIR.mkdir(parents=True, exist_ok=True)
md_file = OUT_PASSPORT_DIR / f"{idx_str}_{word}.md"
with open(md_file, "w", encoding="utf-8") as f:
f.write(render_passport_markdown(entry))
print(f" 📄 Сохранен паспорт в Markdown: {md_file.name}")
print(f"🏁 Завершено для [{idx_str} «{word}»]: обновлено {enriched_this_run} полей (всего {entry.get('filled_count', 0)}/78).")
return enriched_this_run
def print_stats(db: Dict[str, Dict[str, Any]]) -> None:
total = len(db)
filled_counts = [entry.get("filled_count", 0) for entry in db.values()]
complete_count = sum(1 for c in filled_counts if c >= 78)
partially_filled = sum(1 for c in filled_counts if c > 0)
total_fields = sum(filled_counts)
max_possible = total * 78
print("\n" + "=" * 65)
print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ «ОРАКУЛ 999 — ПАСПОРТА ЗНАКОВ»")
print("=" * 65)
print(f" Всего знаков в реестре: {total}")
print(f" Полностью завершено (78/78): {complete_count} ({complete_count/total*100:.1f}%)")
print(f" Частично заполнено (>0): {partially_filled} ({partially_filled/total*100:.1f}%)")
print(f" Всего заполненных ячеек: {total_fields} / {max_possible} ({total_fields/max_possible*100:.2f}%)")
print(f" Среднее кол-во полей/карту: {total_fields/total:.1f} из 78")
# Section breakdown
section_counts: Dict[str, int] = {f"Раздел {s}": 0 for s in ["I", "II", "III", "IV", "V", "VI"]}
for entry in db.values():
flds = entry.get("fields", {})
for p in PROMPTS_78:
if flds.get(p.key):
section_counts[f"Раздел {p.section_num}"] += 1
print("\nРаспределение заполненности по разделам:")
for sec, cnt in section_counts.items():
sec_total = sum(1 for p in PROMPTS_78 if f"Раздел {p.section_num}" == sec) * total
print(f" {sec:<12}: {cnt:>6} / {sec_total} ({cnt/sec_total*100:.1f}%)")
print("=" * 65 + "\n")
def export_prompts_file(entry: Dict[str, Any], out_path: Path) -> None:
idx_num = entry.get("id", 0)
idx_str = f"{idx_num:03d}"
word = entry.get("word", "")
emoji = entry.get("emoji", "")
lines = [
f"# ПАКЕТ 78 ПРОМПТОВ ДЛЯ ЗНАКА: [{idx_str}] «{word.upper()}» {emoji}",
f"# Сгенерировано: {time.strftime('%Y-%m-%d %H:%M:%S')}",
"=" * 70,
"",
]
for p in PROMPTS_78:
p_text = get_field_prompt(p.key, entry)
lines.append(f"### [ПРОМПТ {p.index:02d}/78] | Раздел {p.section_num} | Поле: {p.key}")
lines.append(f"# Название: {p.title}")
lines.append(p_text)
lines.append("")
lines.append("-" * 70)
lines.append("")
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"✅ Экспортировано 78 промптов в файл: {out_path}")
def main():
parser = argparse.ArgumentParser(
description="Автоматический обогатитель единого паспорта знака «Оракула 999» (78 атомарных промптов)"
)
parser.add_argument("--stats", action="store_true", help="Показать общую статистику заполненности базы")
parser.add_argument("--id", type=str, help="ID знака (например '000' или '0,1,2')")
parser.add_argument("--word", type=str, help="Слово для обогащения (например 'огонь')")
parser.add_argument("--range", nargs=2, type=int, metavar=("START", "END"), help="Диапазон индексов (например 0 10)")
parser.add_argument("--all", action="store_true", help="Пройти по всем 1000 знакам")
parser.add_argument("--backend", default="dry-run", choices=["dry-run", "mock", "openai", "openrouter", "deepseek", "ollama", "custom"], help="Бэкенд генерации")
parser.add_argument("--model", default="gpt-4o-mini", help="Имя модели LLM")
parser.add_argument("--api-key", default=None, help="API ключ")
parser.add_argument("--base-url", default=None, help="Базовый URL OpenAI/Ollama")
parser.add_argument("--section", choices=["I", "II", "III", "IV", "V", "VI", "1", "2", "3", "4", "5", "6"], help="Ограничить определенным разделом")
parser.add_argument("--field", help="Ограничить конкретным ключом поля или индексом (1..78)")
parser.add_argument("--force", action="store_true", help="Перезаписывать уже заполненные поля")
parser.add_argument("--delay", type=float, default=0.2, help="Задержка между запросами (сек)")
parser.add_argument("--export-prompts", action="store_true", help="Только экспортировать 78 промптов в файл out/prompts/...")
args = parser.parse_args()
db = load_database()
if args.stats:
print_stats(db)
return
sec_map = {"1": "I", "2": "II", "3": "III", "4": "IV", "5": "V", "6": "VI"}
section_filter = sec_map.get(args.section, args.section)
target_entries: List[Dict[str, Any]] = []
if args.id:
ids = [x.strip() for x in args.id.split(",")]
for i_str in ids:
int_val = int(i_str)
k = str(int_val)
if k in db:
target_entries.append(db[k])
else:
print(f"[WARN] ID {i_str} не найден в базе!")
elif args.word:
w_lower = args.word.strip().lower()
matched = [entry for entry in db.values() if entry.get("word", "").lower() == w_lower]
if matched:
target_entries.extend(matched)
else:
print(f"[WARN] Слово «{args.word}» не найдено в базе!")
elif args.range:
st, ed = args.range
for i in range(st, ed + 1):
k = str(i)
if k in db:
target_entries.append(db[k])
elif args.all:
for i in range(1000):
k = str(i)
if k in db:
target_entries.append(db[k])
else:
print("💡 Не указан знак для обогащения. Используйте:")
print(" --stats Показать статистику")
print(" --word огонь Обогатить по слову")
print(" --id 000 Обогатить по индексу")
print(" --range 0 5 Обогатить диапазон")
print(" --backend dry-run Тестовый запуск без реальных API вызовов")
print(" --export-prompts Сформировать файл с 78 промптами")
print_stats(db)
return
if not target_entries:
print("⚠️ Целевые знаки не найдены.")
return
if args.export_prompts:
for entry in target_entries:
idx_str = f"{entry.get('id', 0):03d}"
word = entry.get("word", "")
out_file = PROJECT_ROOT / "out" / "prompts" / f"passport_{idx_str}_{word}_78prompts.txt"
export_prompts_file(entry, out_file)
return
client = LLMClient(
backend=args.backend,
model=args.model,
api_key=args.api_key,
base_url=args.base_url,
)
print(f"🚀 Запуск обогатителя для {len(target_entries)} знаков.")
print(f" Бэкенд: {client.backend} | Модель: {client.model}")
print(f" Режим перезаписи: {'Да (--force)' if args.force else 'Только пустые ячейки'}")
total_enriched = 0
t_start = time.time()
for entry in target_entries:
count = enrich_symbol(
entry=entry,
client=client,
db=db,
force=args.force,
section_filter=section_filter,
field_filter=args.field,
delay=args.delay,
)
total_enriched += count
duration = time.time() - t_start
print(f"\n🎉 ВСЕ ЗАДАЧИ ВЫПОЛНЕНЫ ЗА {duration:.1f}с!")
print(f" Всего обогащено полей: {total_enriched}")
print_stats(db)
if __name__ == "__main__":
main()