refactor: API migration to direct Obsidian REST and test stabilization
This commit is contained in:
parent
3c3a1eb492
commit
ce55b9ca9a
32
Readme.md
32
Readme.md
|
|
@ -2,23 +2,27 @@
|
||||||
|
|
||||||
В данной директории содержатся скрипты и утилиты для автоматизации и генерации контента в TTRPG-кампании.
|
В данной директории содержатся скрипты и утилиты для автоматизации и генерации контента в TTRPG-кампании.
|
||||||
|
|
||||||
## Поддиректории
|
## Дерево основных файлов и папок
|
||||||
- **dice_roller/** — скрипты для бросков кубиков.
|
|
||||||
- **faction_turns/** — система ходов фракций по правилам Worlds Without Number (WWN).
|
|
||||||
- **generate_encounter/** — генераторы боевых и случайных столкновений.
|
|
||||||
- **generate_npc/** — генерация NPC (характеристики, био, классы).
|
|
||||||
- **generate_trap/** — генерация ловушек.
|
|
||||||
- **generate_treasure/** — генерация сокровищ и лута.
|
|
||||||
- **time/** — управление временем в кампании.
|
|
||||||
- **weather/** — генерация погодных условий.
|
|
||||||
|
|
||||||
## Основные файлы
|
```text
|
||||||
- `vault_utils.py` — базовые утилиты для взаимодействия с Obsidian (чтение/запись файлов, поиск).
|
scripts/
|
||||||
- `game_utils.py` — общие игровые утилиты.
|
├── dice_roller/ # Скрипты для бросков кубиков
|
||||||
- `obsidian_rest_api.md` — документация по работе с Obsidian Local REST API.
|
├── faction_turns/ # Система ходов фракций (WWN)
|
||||||
|
├── generate_encounter/ # Генераторы столкновений и энкаунтеров
|
||||||
|
├── generate_npc/ # Генерация NPC (био, классы, характеристики)
|
||||||
|
├── generate_trap/ # Генерация ловушек
|
||||||
|
├── generate_treasure/ # Генерация лута и сокровищ
|
||||||
|
├── time/ # Управление временем кампании
|
||||||
|
├── weather/ # Генерация погодных условий
|
||||||
|
├── game_utils.py # Общие игровые утилиты
|
||||||
|
├── obsidian_rest_api.md # Документация по работе с Obsidian REST API
|
||||||
|
├── Readme.md # Описание структуры проекта (этот файл)
|
||||||
|
├── TASKS.md # План работ и задачи
|
||||||
|
└── vault_utils.py # Утилиты для работы с Obsidian REST API
|
||||||
|
```
|
||||||
|
|
||||||
## Тестирование и сетевые настройки
|
## Тестирование и сетевые настройки
|
||||||
- При запуске интеграционных тестов к Obsidian Local REST API (`127.0.0.1`) системный прокси должен быть отключен (`proxies={"http": None, "https": None}`), иначе запросы к локальному хосту перенаправляются на внешние прокси-порты и падают с ошибкой подключения.
|
- При запуске интеграционных тестов к Obsidian Local REST API (`127.0.0.1`) системный прокси должен быть отключен (`proxies={"http": None, "https": None}`), иначе запросы к локальному хосту перенаправляются на внешние прокси-порты и падают с ошибкой подключения.
|
||||||
|
|
||||||
<!-- Agents: ВНИМАНИЕ ИИ-АГЕНТАМ И АВТОМАТИЗИРОВАННЫМ СИСТЕМАМ!
|
<!-- Agents: ВНИМАНИЕ ИИ-АГЕНТАМ И АВТОМАТИЗИРОВАННЫМ СИСТЕМАМ!
|
||||||
Данный файл README.md необходимо поддерживать в актуальном состоянии. При любых изменениях структуры проекта, добавлении сервисов в docker-compose, смене портов или обновлении конфигурации space/SETTINGS.md обязательно обновите этот файл README.md. -->
|
Данный файл README.md необходимо поддерживать в актуальном состоянии. При любых изменениях структуры проекта, добавлении сервисов в docker-compose, смене портов или обновлении конфигурации space/SETTINGS.md обязательно обновите этот файл README.md. -->
|
||||||
24
TASKS.md
Normal file
24
TASKS.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# План работ по рефакторингу API и расширению тестов
|
||||||
|
|
||||||
|
## 1. Рефакторинг vault_utils.py
|
||||||
|
- [x] Заменить единый `vault_query` с хардкодом строк на экспортируемые прямые функции.
|
||||||
|
- [x] Обновить XML-doc (docstrings) для всех методов.
|
||||||
|
- [x] Добавить универсальную функцию `get_recent_files` (на базе JsonLogic).
|
||||||
|
|
||||||
|
## 2. Рефакторинг faction_utils.py
|
||||||
|
- [x] Перевести вызовы с `vault_query(...)` на прямые вызовы API.
|
||||||
|
- [x] Переписать `get_all_factions` и `get_recent_events` как обёртки вокруг `get_recent_files`.
|
||||||
|
|
||||||
|
## 3. Рефакторинг time_utils.py
|
||||||
|
- [x] Обновить импорты из `vault_utils`.
|
||||||
|
- [x] Заменить `vault_query` на прямые методы (`get_file`, `write_file`).
|
||||||
|
|
||||||
|
## 4. Исследование Obsidian Local REST API
|
||||||
|
- [x] Добавить примеры JSONLogic-запросов и PowerShell-скрипт с сортировкой.
|
||||||
|
- [x] Очистить документацию от сбоев форматирования Markdown ссылок.
|
||||||
|
|
||||||
|
## 5. Расширение тестов фракций
|
||||||
|
- [ ] Дописать интеграционные тесты для `get_recent_events`, `get_location` и работы с активами.
|
||||||
|
- [ ] Расширить тесты логики ходов фракций в `test_wwn_mechanics.py`.
|
||||||
|
- [x] Добавлены setup-тесты для перезаписи тестовых файлов фракций (frontmatter и контент) для детерминированности среды.
|
||||||
|
- [x] Убедиться, что текущий набор pytest проходит без ошибок (`pytest -v`).
|
||||||
|
|
@ -39,7 +39,11 @@ if str(scripts_path) not in sys.path:
|
||||||
sys.path.insert(0, str(scripts_path))
|
sys.path.insert(0, str(scripts_path))
|
||||||
|
|
||||||
from vault_utils import (
|
from vault_utils import (
|
||||||
vault_query,
|
get_file,
|
||||||
|
search_simple,
|
||||||
|
search_jsonlogic,
|
||||||
|
list_directory,
|
||||||
|
get_recent_files,
|
||||||
write_file as write_faction, # алиас для обратной совместимости
|
write_file as write_faction, # алиас для обратной совместимости
|
||||||
create_file as create_event_file, # алиас для обратной совместимости
|
create_file as create_event_file, # алиас для обратной совместимости
|
||||||
normalize_wikilink,
|
normalize_wikilink,
|
||||||
|
|
@ -73,17 +77,7 @@ def get_all_factions(
|
||||||
"frontmatter": { ... }
|
"frontmatter": { ... }
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
result = vault_query("get_all_factions", {
|
factions = get_recent_files(factions_folder, tags=["faction"])
|
||||||
"folder": factions_folder,
|
|
||||||
"tag_filter": "faction"
|
|
||||||
})
|
|
||||||
if isinstance(result, list):
|
|
||||||
factions = result
|
|
||||||
elif isinstance(result, dict):
|
|
||||||
factions = result.get("data", [])
|
|
||||||
else:
|
|
||||||
factions = []
|
|
||||||
|
|
||||||
for f in factions:
|
for f in factions:
|
||||||
f["frontmatter"] = normalize_faction_frontmatter(f.get("frontmatter", {}))
|
f["frontmatter"] = normalize_faction_frontmatter(f.get("frontmatter", {}))
|
||||||
return factions
|
return factions
|
||||||
|
|
@ -101,7 +95,7 @@ def get_faction(file_path: str) -> dict:
|
||||||
"body": "# Лор фракции..."
|
"body": "# Лор фракции..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return vault_query("get_frontmatter", {"path": file_path})
|
return get_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
def get_active_factions(
|
def get_active_factions(
|
||||||
|
|
@ -201,7 +195,7 @@ def get_location(wikilink_or_path: str) -> dict:
|
||||||
else:
|
else:
|
||||||
path = wikilink_or_path
|
path = wikilink_or_path
|
||||||
|
|
||||||
return vault_query("get_frontmatter", {"path": path})
|
return get_file(path)
|
||||||
|
|
||||||
|
|
||||||
def get_travel_distance(
|
def get_travel_distance(
|
||||||
|
|
@ -306,14 +300,7 @@ def get_recent_events(
|
||||||
"frontmatter": { ... }
|
"frontmatter": { ... }
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
result = vault_query("get_recent_files", {
|
return get_recent_files(events_folder, tags=tags_filter, max_count=max_count)
|
||||||
"folder": events_folder,
|
|
||||||
"limit": max_count,
|
|
||||||
"tags": tags_filter or []
|
|
||||||
})
|
|
||||||
if isinstance(result, list):
|
|
||||||
return result
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
25
faction_turns/tests/check_search_syntax.py
Normal file
25
faction_turns/tests/check_search_syntax.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
from vault_utils import BASE_URL, API_KEY
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
||||||
|
proxies = {"http": None, "https": None}
|
||||||
|
|
||||||
|
queries = ["path:Фракции/Tests", "path:Tests", "Фракции", "Tests"]
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
print(f"--- Testing query: {q} ---")
|
||||||
|
try:
|
||||||
|
resp = requests.post(f"{BASE_URL}/search/simple/", headers=headers, params={"query": q}, verify=False, proxies=proxies)
|
||||||
|
print("Status:", resp.status_code)
|
||||||
|
data = resp.json()
|
||||||
|
print("Results count:", len(data))
|
||||||
|
if data:
|
||||||
|
print("First result:", data[0].get('filename'))
|
||||||
|
except Exception as e:
|
||||||
|
print("Error:", e)
|
||||||
20
faction_turns/tests/debug_search.py
Normal file
20
faction_turns/tests/debug_search.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
from vault_utils import BASE_URL, API_KEY
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||||
|
proxies = {"http": None, "https": None}
|
||||||
|
|
||||||
|
queries = ["Фракции/Tests", "Фракции", "Tests", "Test_Faction"]
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
print(f"--- Testing query: {q} ---")
|
||||||
|
resp = requests.post(f"{BASE_URL}/search/simple/", headers=headers, params={"query": q}, verify=False, proxies=proxies)
|
||||||
|
print("Status:", resp.status_code)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
results = resp.json()
|
||||||
|
print("Found files:", [r.get('filename') for r in results[:5]])
|
||||||
|
else:
|
||||||
|
print("Error:", resp.text)
|
||||||
65
faction_turns/tests/test_faction_integration.py
Normal file
65
faction_turns/tests/test_faction_integration.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from faction_utils import get_faction, get_all_factions
|
||||||
|
from vault_utils import BASE_URL, API_KEY, write_file
|
||||||
|
|
||||||
|
# ----------------------------------------------- Faction Integration Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_obsidian_connection():
|
||||||
|
"""Проверяет наличие связи с плагином Obsidian Local REST API."""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.get(BASE_URL + "/", headers=headers, verify=False, proxies={"http": None, "https": None})
|
||||||
|
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}. Response: {resp.text}"
|
||||||
|
|
||||||
|
def test_setup_test_factions():
|
||||||
|
"""Подготавливает тестовые файлы фракций, перезаписывая их содержимое для предсказуемости тестов."""
|
||||||
|
fm_a = {
|
||||||
|
"name": "Test_Faction_A",
|
||||||
|
"tags": ["faction"],
|
||||||
|
"force": 4,
|
||||||
|
"treasure": 10,
|
||||||
|
"assets": [{"name": "Asset1"}, {"name": "Asset2"}]
|
||||||
|
}
|
||||||
|
assert write_file("Фракции/Tests/Test_Faction_A.md", fm_a, "# Test Faction A\nФайл для автоматических тестов.") is True
|
||||||
|
|
||||||
|
fm_b = {
|
||||||
|
"name": "Test_Faction_B",
|
||||||
|
"tags": ["faction"],
|
||||||
|
"wealth": 4,
|
||||||
|
"cunning": 2
|
||||||
|
}
|
||||||
|
assert write_file("Фракции/Tests/Test_Faction_B.md", fm_b, "# Test Faction B\nФайл для автоматических тестов.") is True
|
||||||
|
|
||||||
|
def test_get_faction_integration():
|
||||||
|
"""Проверяет чтение конкретной тестовой фракции через vault_utils API."""
|
||||||
|
faction = get_faction("Фракции/Tests/Test_Faction_A.md")
|
||||||
|
assert faction is not None
|
||||||
|
assert faction.get("name") == "Test_Faction_A"
|
||||||
|
fm = faction.get("frontmatter", {})
|
||||||
|
assert fm.get("force") == 4
|
||||||
|
assert fm.get("treasure") == 10
|
||||||
|
assert len(fm.get("assets", [])) == 2
|
||||||
|
|
||||||
|
def test_get_all_factions_integration():
|
||||||
|
"""Проверяет загрузку списка тестовых фракций из директории Tests."""
|
||||||
|
factions = get_all_factions("Фракции/Tests")
|
||||||
|
assert len(factions) >= 2
|
||||||
|
names = [f.get("name") for f in factions]
|
||||||
|
assert "Test_Faction_A" in names
|
||||||
|
assert "Test_Faction_B" in names
|
||||||
|
|
||||||
|
faction_b = next(f for f in factions if f.get("name") == "Test_Faction_B")
|
||||||
|
fm_b = faction_b.get("frontmatter", {})
|
||||||
|
assert fm_b.get("wealth") == 4
|
||||||
|
assert fm_b.get("cunning") == 2
|
||||||
34
faction_turns/tests/test_faction_utils.py
Normal file
34
faction_turns/tests/test_faction_utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем родительскую директорию в sys.path для импорта модулей
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from faction_utils import roll_dice, normalize_wikilink
|
||||||
|
|
||||||
|
# ----------------------------------------------- Dice Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_roll_dice_static():
|
||||||
|
"""Проверяет парсинг нотации кубиков и вычисление статических бросков."""
|
||||||
|
assert roll_dice("1d1+5") == 6
|
||||||
|
assert roll_dice("2d1-1") == 1
|
||||||
|
assert roll_dice("10d1+0") == 10
|
||||||
|
|
||||||
|
def test_roll_dice_random():
|
||||||
|
"""Проверяет, что результаты случайных бросков кубиков находятся в ожидаемых границах."""
|
||||||
|
result = roll_dice("1d20")
|
||||||
|
assert 1 <= result <= 20
|
||||||
|
|
||||||
|
result = roll_dice("2d6+2")
|
||||||
|
assert 4 <= result <= 14
|
||||||
|
|
||||||
|
# ----------------------------------------------- String Parsing Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_normalize_wikilink():
|
||||||
|
"""Проверяет нормализацию синтаксиса Obsidian wikilink в чистую строку."""
|
||||||
|
assert normalize_wikilink("[[Waterdeep]]") == "waterdeep"
|
||||||
|
assert normalize_wikilink("[[Waterdeep|City]]") == "waterdeep"
|
||||||
|
assert normalize_wikilink("Waterdeep") == "waterdeep"
|
||||||
|
assert normalize_wikilink("[[Neverwinter|The Jewel of the North]]") == "neverwinter"
|
||||||
|
assert normalize_wikilink("") == ""
|
||||||
79
faction_turns/tests/test_vault_utils.py
Normal file
79
faction_turns/tests/test_vault_utils.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""
|
||||||
|
Интеграционные тесты для vault_utils.py.
|
||||||
|
Проверяют взаимодействие с Obsidian Local REST API по всем реализованным кейсам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Добавляем родительскую директорию scripts в sys.path
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||||
|
|
||||||
|
from vault_utils import (
|
||||||
|
get_file, write_file, create_file, patch_frontmatter, search_simple, list_directory,
|
||||||
|
normalize_wikilink, resolve_wikilink
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------- Test Constants ------------------------------------------------------------
|
||||||
|
TEST_FOLDER = "Фракции/Tests"
|
||||||
|
TEST_FILE_NAME_NO_EXT = "Test_Vault_Utils"
|
||||||
|
TEST_FILE_NAME = TEST_FILE_NAME_NO_EXT + ".md"
|
||||||
|
TEST_FILE_PATH = f"{TEST_FOLDER}/{TEST_FILE_NAME}"
|
||||||
|
|
||||||
|
# ----------------------------------------------- Tests ------------------------------------------------------------
|
||||||
|
def test_create_file():
|
||||||
|
"""Проверяет создание нового файла (PUT)."""
|
||||||
|
content = "# Test Vault Utils\nТестовый контент."
|
||||||
|
success = create_file(TEST_FILE_PATH, content)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
def test_list_directory():
|
||||||
|
"""Проверяет получение списка файлов в директории (GET)."""
|
||||||
|
result = list_directory(TEST_FOLDER)
|
||||||
|
assert "files" in result
|
||||||
|
assert any(TEST_FILE_NAME in f for f in result["files"])
|
||||||
|
|
||||||
|
def test_search_simple():
|
||||||
|
"""Проверяет простой поиск по имени файла (POST)."""
|
||||||
|
result = search_simple("Test_Vault_Utils")
|
||||||
|
assert len(result) > 0
|
||||||
|
assert result[0]["filename"] == TEST_FILE_PATH
|
||||||
|
|
||||||
|
def test_get_file():
|
||||||
|
"""Проверяет чтение контента и парсинг frontmatter (GET)."""
|
||||||
|
result = get_file(TEST_FILE_PATH)
|
||||||
|
assert result["path"] == TEST_FILE_PATH
|
||||||
|
assert "Test Vault Utils" in result["body"]
|
||||||
|
|
||||||
|
def test_write_file():
|
||||||
|
"""Проверяет перезапись файла с frontmatter (PUT)."""
|
||||||
|
fm = {"test_field": "test_value"}
|
||||||
|
body = "# Updated Title\nНовый текст."
|
||||||
|
success = write_file(TEST_FILE_PATH, fm, body)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
# Проверяем, что изменения применились
|
||||||
|
result = get_file(TEST_FILE_PATH)
|
||||||
|
assert result["frontmatter"].get("test_field") == "test_value"
|
||||||
|
|
||||||
|
def test_patch_frontmatter():
|
||||||
|
"""Проверяет точечное изменение frontmatter (PATCH)."""
|
||||||
|
success = patch_frontmatter(
|
||||||
|
TEST_FILE_PATH,
|
||||||
|
target="patched_field",
|
||||||
|
value=42
|
||||||
|
)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
def test_normalize_wikilink():
|
||||||
|
"""Проверяет нормализацию вики-ссылок."""
|
||||||
|
assert normalize_wikilink("[[Waterdeep]]") == "waterdeep"
|
||||||
|
assert normalize_wikilink("[[Waterdeep|City]]") == "waterdeep"
|
||||||
|
assert normalize_wikilink("Waterdeep") == "waterdeep"
|
||||||
|
assert normalize_wikilink("") == ""
|
||||||
|
|
||||||
|
def test_resolve_wikilink():
|
||||||
|
"""Проверяет разрешение пути по имени вики-ссылки."""
|
||||||
|
resolved = resolve_wikilink(f"[[{TEST_FILE_NAME_NO_EXT}]]")
|
||||||
|
assert resolved == TEST_FILE_PATH
|
||||||
34
faction_turns/tests/test_wwn_mechanics.py
Normal file
34
faction_turns/tests/test_wwn_mechanics.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем родительскую директорию в sys.path для импорта модулей
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from wwn_mechanics import calc_hp_max, calc_income, calc_xp_cost_to_raise
|
||||||
|
|
||||||
|
# ----------------------------------------------- HP Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_calc_hp_max():
|
||||||
|
"""Проверяет корректность расчета максимального HP фракции на основе атрибутов."""
|
||||||
|
assert calc_hp_max(2, 4, 5) == 17
|
||||||
|
assert calc_hp_max(3, 3, 3) == 12
|
||||||
|
assert calc_hp_max(1, 1, 1) == 3
|
||||||
|
assert calc_hp_max(8, 8, 8) == 60
|
||||||
|
|
||||||
|
# ----------------------------------------------- Income Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_calc_income():
|
||||||
|
"""Проверяет правильность расчета дохода фракции (Treasure income) за один ход."""
|
||||||
|
assert calc_income(4, 2, 5) == 4
|
||||||
|
assert calc_income(2, 1, 1) == 2
|
||||||
|
assert calc_income(8, 7, 6) == 8
|
||||||
|
|
||||||
|
# ----------------------------------------------- XP Tests ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_calc_xp_cost_to_raise():
|
||||||
|
"""Проверяет получение стоимости в XP для повышения значения атрибута."""
|
||||||
|
assert calc_xp_cost_to_raise(1) == 2
|
||||||
|
assert calc_xp_cost_to_raise(4) == 9
|
||||||
|
assert calc_xp_cost_to_raise(7) == 20
|
||||||
|
assert calc_xp_cost_to_raise(8) is None
|
||||||
18
faction_turns/tests/verify_factions.py
Normal file
18
faction_turns/tests/verify_factions.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем путь к scripts, чтобы импортировать vault_utils
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
from vault_utils import vault_query
|
||||||
|
|
||||||
|
# Проверяем работу get_all_factions
|
||||||
|
folder = "Фракции/Tests"
|
||||||
|
print(f"--- Проверка загрузки для папки: {folder} ---")
|
||||||
|
|
||||||
|
# Прямой вызов get_all_factions через vault_query
|
||||||
|
result = vault_query("get_all_factions", {"folder": folder})
|
||||||
|
|
||||||
|
print("Результат:", result)
|
||||||
|
|
||||||
|
# Дополнительно: выведем сырой список, если это возможно, чтобы увидеть, что пришло из поиска
|
||||||
|
# (исходя из логики vault_utils, результат - это список словарей)
|
||||||
|
|
@ -8,7 +8,7 @@ Here is the transcribed text from the provided images, with the values updated t
|
||||||
|
|
||||||
You can access the Obsidian local REST API & MCP server via the following URL:
|
You can access the Obsidian local REST API & MCP server via the following URL:
|
||||||
|
|
||||||
* **Encrypted (HTTPS) API URL:** `[https://127.0.0.1:27124/](https://127.0.0.1:27124/)`
|
* **Encrypted (HTTPS) API URL:** `https://127.0.0.1:27124/`
|
||||||
|
|
||||||
*Requires that this certificate be configured as a trusted certificate authority for your browser.*
|
*Requires that this certificate be configured as a trusted certificate authority for your browser.*
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ Your API key should be passed as a bearer token via the `Authorization` header:
|
||||||
|
|
||||||
You can connect to the MCP server via the following endpoint:
|
You can connect to the MCP server via the following endpoint:
|
||||||
|
|
||||||
* **Encrypted (HTTPS) MCP Endpoint:** `[https://127.0.0.1:27124/mcp/](https://127.0.0.1:27124/mcp/)`
|
* **Encrypted (HTTPS) MCP Endpoint:** `https://127.0.0.1:27124/mcp/`
|
||||||
|
|
||||||
*Requires that this certificate be configured as a trusted certificate authority.*
|
*Requires that this certificate be configured as a trusted certificate authority.*
|
||||||
|
|
||||||
|
|
@ -255,3 +255,4 @@ $body = [System.Text.Encoding]::UTF8.GetBytes("# New Faction`r`nКонтент
|
||||||
Invoke-RestMethod -Method Put -Uri $uri -Headers $headers -Body $body
|
Invoke-RestMethod -Method Put -Uri $uri -Headers $headers -Body $body
|
||||||
```
|
```
|
||||||
|
|
||||||
|
8) Примеры для вытягивания последних N записей довольны сложны даже через powershell, пока не стал добавлять
|
||||||
36
test_jsonlogic.py
Normal file
36
test_jsonlogic.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
API_KEY = "31c4ee60e78cea8503d0ac076298a0da6f5cbc3992f4e4cdc97d84fcbf27248e"
|
||||||
|
BASE_URL = "https://127.0.0.1:27124"
|
||||||
|
HEADERS = {
|
||||||
|
"Authorization": f"Bearer {API_KEY}",
|
||||||
|
"Content-Type": "application/vnd.olrapi.jsonlogic+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_search():
|
||||||
|
query = {
|
||||||
|
"glob": ["*/*.md", {"var": ""}]
|
||||||
|
}
|
||||||
|
print("Sending request...")
|
||||||
|
resp = requests.post(
|
||||||
|
f"{BASE_URL}/search/",
|
||||||
|
headers=HEADERS,
|
||||||
|
json=query,
|
||||||
|
verify=False,
|
||||||
|
proxies={"http": None, "https": None}
|
||||||
|
)
|
||||||
|
print("Status:", resp.status_code)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
results = resp.json()
|
||||||
|
print(f"Found {len(results)} results.")
|
||||||
|
if results:
|
||||||
|
print("First result sample:", json.dumps(results[0], indent=2))
|
||||||
|
else:
|
||||||
|
print("Error:", resp.text)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_search()
|
||||||
|
|
@ -55,4 +55,7 @@ def get_campaign_time(debug_description: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
print(get_campaign_time("тест"))
|
print(get_campaign_time("тест"))
|
||||||
|
|
@ -212,6 +212,9 @@ def set_campaign_time(debug_description: str, requests: list[TimeChangeRequest])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
result = set_campaign_time("тест", [
|
result = set_campaign_time("тест", [
|
||||||
{"mode": "shift", "day": 1, "hour": 5},
|
{"mode": "shift", "day": 1, "hour": 5},
|
||||||
{"mode": "absolute", "year": 1492, "month": 1, "day": 1, "hour": 0},
|
{"mode": "absolute", "year": 1492, "month": 1, "day": 1, "hour": 0},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ scripts_path = Path(__file__).resolve().parent.parent
|
||||||
if str(scripts_path) not in sys.path:
|
if str(scripts_path) not in sys.path:
|
||||||
sys.path.insert(0, str(scripts_path))
|
sys.path.insert(0, str(scripts_path))
|
||||||
|
|
||||||
from vault_utils import vault_query, write_file, create_file
|
from vault_utils import get_file, write_file, create_file
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# НАЗВАНИЯ МЕСЯЦЕВ (только русские)
|
# НАЗВАНИЯ МЕСЯЦЕВ (только русские)
|
||||||
|
|
@ -133,7 +133,7 @@ def _normalize_time(year: int, month: int, day: int, hour: int) -> tuple[int, in
|
||||||
|
|
||||||
def get_campaign_time() -> dict:
|
def get_campaign_time() -> dict:
|
||||||
try:
|
try:
|
||||||
data = vault_query("get_frontmatter", {"path": CAMPAIGN_TIME_PATH})
|
data = get_file(CAMPAIGN_TIME_PATH)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
data = None
|
data = None
|
||||||
|
|
||||||
|
|
|
||||||
278
vault_utils.py
278
vault_utils.py
|
|
@ -1,135 +1,193 @@
|
||||||
"""
|
"""
|
||||||
vault_utils.py — универсальные утилиты для работы с Obsidian vault через HTTP-прокси.
|
vault_utils.py — контексто-независимые утилиты для работы с Obsidian REST API.
|
||||||
|
Универсальный модуль без привязки к игровой специфике (фракциям, событиям и т.д.).
|
||||||
Содержит функции, переиспользуемые любыми модулями (фракции, время, события и т.д.).
|
|
||||||
|
|
||||||
Экспортирует:
|
|
||||||
vault_query(query_type, payload) — базовый вызов к бэкенду
|
|
||||||
write_file(path, frontmatter, body) — запись frontmatter и тела файла
|
|
||||||
create_file(path, content) — создание файла с содержимым
|
|
||||||
normalize_wikilink(s) — приведение wikilink к нормализованному виду
|
|
||||||
resolve_wikilink(name) — поиск пути к файлу по имени/алиасу
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import requests as http_requests
|
import yaml
|
||||||
from typing import Optional
|
import requests
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ----------------------------------------------- Configuration ------------------------------------------------------------
|
||||||
# Настройки
|
REST_API_PORT = 27124
|
||||||
# ---------------------------------------------------------------------------
|
API_KEY = "31c4ee60e78cea8503d0ac076298a0da6f5cbc3992f4e4cdc97d84fcbf27248e"
|
||||||
|
BASE_URL = f"https://127.0.0.1:{REST_API_PORT}"
|
||||||
|
|
||||||
BACKEND_URL = "http://localhost:5000/api"
|
def _get_headers() -> Dict[str, str]:
|
||||||
VAULT_QUERY_TIMEOUT = 12 # секунд
|
"""Возвращает базовые заголовки для запросов к REST API."""
|
||||||
|
return {"Authorization": f"Bearer {API_KEY}", "Content-Type": "text/markdown"}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _get_req_kwargs() -> Dict[str, Any]:
|
||||||
# Базовый клиент
|
"""Возвращает параметры для отключения SSL и прокси."""
|
||||||
# ---------------------------------------------------------------------------
|
return {"verify": False, "proxies": {"http": None, "https": None}}
|
||||||
|
|
||||||
def vault_query(query_type: str, payload: dict) -> any:
|
# ----------------------------------------------- Parsers ------------------------------------------------------------
|
||||||
|
def _parse_obsidian_content(content: str, path: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Отправляет запрос к Obsidian vault через бэкенд-прокси.
|
Парсит контент из Markdown в структуру с Frontmatter.
|
||||||
|
|
||||||
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
|
|
||||||
Бросает RuntimeError при недоступности бэкенда или timeout.
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
query_type: тип операции
|
|
||||||
payload: параметры операции
|
|
||||||
"""
|
"""
|
||||||
try:
|
if content.startswith("---"):
|
||||||
resp = http_requests.post(
|
parts = content.split("---", 2)
|
||||||
f"{BACKEND_URL}/vault/query",
|
if len(parts) >= 3:
|
||||||
json={"type": query_type, "payload": payload},
|
return {
|
||||||
timeout=VAULT_QUERY_TIMEOUT,
|
"path": path,
|
||||||
proxies={"http": None, "https": None}
|
"frontmatter": yaml.safe_load(parts[1]) or {},
|
||||||
)
|
"body": parts[2].strip(),
|
||||||
except http_requests.exceptions.ConnectionError as e:
|
"name": path.split('/')[-1].replace('.md', '')
|
||||||
raise RuntimeError(
|
}
|
||||||
f"Бэкенд недоступен ({BACKEND_URL}). "
|
return {"path": path, "frontmatter": {}, "body": content, "name": path.split('/')[-1]}
|
||||||
"Убедитесь что Flask-сервер запущен."
|
|
||||||
)
|
|
||||||
except http_requests.exceptions.Timeout:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"vault_query timeout после {VAULT_QUERY_TIMEOUT}с. "
|
|
||||||
"Возможно Obsidian не подключён к WebSocket."
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 504:
|
# ----------------------------------------------- Core API ------------------------------------------------------------
|
||||||
raise RuntimeError(
|
def get_file(path: str) -> Dict[str, Any]:
|
||||||
"Obsidian не ответил на запрос vault за 10 секунд. "
|
"""Получает файл из Obsidian и возвращает распарсенный контент."""
|
||||||
"Проверьте что плагин LLM Agent запущен и подключён."
|
encoded_path = urllib.parse.quote(path)
|
||||||
)
|
resp = requests.get(f"{BASE_URL}/vault/{encoded_path}", headers=_get_headers(), **_get_req_kwargs())
|
||||||
|
if resp.status_code == 200:
|
||||||
if not resp.ok:
|
return _parse_obsidian_content(resp.text, path)
|
||||||
try:
|
raise RuntimeError(f"Файл не найден: {path}")
|
||||||
err = resp.json().get("error", resp.text)
|
|
||||||
except Exception:
|
|
||||||
err = resp.text
|
|
||||||
raise RuntimeError(f"vault_query ошибка {resp.status_code}: {err}")
|
|
||||||
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Запись данных (универсальные)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def write_file(file_path: str, frontmatter: dict, body: str) -> bool:
|
def write_file(file_path: str, frontmatter: dict, body: str) -> bool:
|
||||||
"""
|
"""Сохраняет frontmatter и тело файла в Obsidian vault."""
|
||||||
Сохраняет frontmatter и тело файла в Obsidian vault.
|
encoded_path = urllib.parse.quote(file_path)
|
||||||
|
fm = yaml.dump(frontmatter, allow_unicode=True)
|
||||||
file_path — путь внутри vault, например 'Фракции/Cult_of_the_Dragon.md'
|
content = f"---\n{fm}---\n\n{body}"
|
||||||
frontmatter — словарь с данными (поля YAML)
|
resp = requests.put(
|
||||||
body — содержимое файла после frontmatter
|
f"{BASE_URL}/vault/{encoded_path}",
|
||||||
"""
|
headers=_get_headers(),
|
||||||
result = vault_query("write_frontmatter", {
|
data=content.encode('utf-8'),
|
||||||
"path": file_path,
|
**_get_req_kwargs()
|
||||||
"frontmatter": frontmatter,
|
)
|
||||||
"body": body
|
return resp.status_code in [200, 201, 204]
|
||||||
})
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result.get("success", False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def create_file(file_path: str, content: str) -> bool:
|
def create_file(file_path: str, content: str) -> bool:
|
||||||
"""
|
"""Создаёт новый файл в Obsidian vault с указанным контентом."""
|
||||||
Создаёт файл в Obsidian vault (перезаписывает, если существует).
|
encoded_path = urllib.parse.quote(file_path)
|
||||||
Промежуточные папки создаются автоматически.
|
resp = requests.put(
|
||||||
"""
|
f"{BASE_URL}/vault/{encoded_path}",
|
||||||
result = vault_query("create_file", {
|
headers=_get_headers(),
|
||||||
"path": file_path,
|
data=content.encode('utf-8'),
|
||||||
"content": content
|
**_get_req_kwargs()
|
||||||
|
)
|
||||||
|
return resp.status_code in [200, 201, 204]
|
||||||
|
|
||||||
|
def patch_frontmatter(path: str, target: str, value: Any, operation: str = "replace") -> bool:
|
||||||
|
"""Обновляет или добавляет поле во frontmatter файла."""
|
||||||
|
encoded_path = urllib.parse.quote(path)
|
||||||
|
headers = _get_headers()
|
||||||
|
headers.update({
|
||||||
|
"Operation": operation,
|
||||||
|
"Target-Type": "frontmatter",
|
||||||
|
"Target": target,
|
||||||
|
"Create-Target-If-Missing": "true",
|
||||||
|
"Content-Type": "application/json"
|
||||||
})
|
})
|
||||||
if isinstance(result, dict):
|
val = str(value)
|
||||||
return result.get("success", False)
|
resp = requests.patch(f"{BASE_URL}/vault/{encoded_path}", headers=headers, data=val.encode('utf-8'), **_get_req_kwargs())
|
||||||
return False
|
return resp.status_code in [200, 204]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def search_simple(query: str) -> list[dict]:
|
||||||
# Нормализация и резолвинг wikilink
|
"""Выполняет простой текстовый поиск в Obsidian."""
|
||||||
# ---------------------------------------------------------------------------
|
resp = requests.post(
|
||||||
|
f"{BASE_URL}/search/simple/",
|
||||||
|
headers=_get_headers(),
|
||||||
|
params={"query": query},
|
||||||
|
**_get_req_kwargs()
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def search_jsonlogic(query: dict) -> list[dict]:
|
||||||
|
"""Выполняет поиск по правилам JsonLogic в Obsidian."""
|
||||||
|
headers = _get_headers()
|
||||||
|
headers["Content-Type"] = "application/vnd.olrapi.jsonlogic+json"
|
||||||
|
resp = requests.post(
|
||||||
|
f"{BASE_URL}/search/",
|
||||||
|
headers=headers,
|
||||||
|
json=query,
|
||||||
|
**_get_req_kwargs()
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_directory(path: str) -> dict:
|
||||||
|
"""Возвращает список файлов и папок в указанной директории."""
|
||||||
|
encoded_path = urllib.parse.quote(path) if path else ""
|
||||||
|
resp = requests.get(f"{BASE_URL}/vault/{encoded_path}", headers=_get_headers(), **_get_req_kwargs())
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return {"files": []}
|
||||||
|
|
||||||
|
# ----------------------------------------------- Utilities ------------------------------------------------------------
|
||||||
def normalize_wikilink(s: str) -> str:
|
def normalize_wikilink(s: str) -> str:
|
||||||
"""
|
"""Приводит wikilink к нижнему регистру без скобок и алиасов."""
|
||||||
Приводит wikilink к нижнему регистру без скобок и алиасов.
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
'[[Waterdeep]]' → 'waterdeep'
|
|
||||||
'[[Waterdeep|City]]' → 'waterdeep'
|
|
||||||
'Waterdeep' → 'waterdeep'
|
|
||||||
'' → ''
|
|
||||||
"""
|
|
||||||
if not s:
|
if not s:
|
||||||
return ''
|
return ''
|
||||||
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
|
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
|
||||||
|
|
||||||
def resolve_wikilink(name: str) -> Optional[str]:
|
def resolve_wikilink(name: str) -> Optional[str]:
|
||||||
"""
|
"""Ищет путь к файлу по имени wikilink через простой поиск."""
|
||||||
Резолвит wikilink в путь к файлу через Obsidian metadataCache.
|
|
||||||
Знает об aliases.
|
|
||||||
|
|
||||||
Принимает '[[Waterdeep]]', '[[Waterdeep|City]]' или просто 'Waterdeep'.
|
|
||||||
Возвращает путь вида 'TTRPG/Locations/Waterdeep.md' или None.
|
|
||||||
"""
|
|
||||||
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
|
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
|
||||||
result = vault_query("resolve_wikilink", {"name": original_clean})
|
results = search_simple(original_clean)
|
||||||
return result if isinstance(result, str) else None
|
return results[0].get("filename") if results else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_files(
|
||||||
|
folder: str,
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
max_count: Optional[int] = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Ищет файлы в папке, фильтрует по тегам и возвращает отсортированными по убыванию даты изменения (mtime).
|
||||||
|
Использует пакетную загрузку JsonLogic для минимизации запросов к API.
|
||||||
|
"""
|
||||||
|
conditions = [{"glob": [f"{folder}/*.md", {"var": "path"}]}]
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
tag_conditions = [{"in": [tag.lstrip('#'), {"var": "tags"}]} for tag in tags]
|
||||||
|
conditions.append({"or": tag_conditions})
|
||||||
|
|
||||||
|
query = {
|
||||||
|
"if": [
|
||||||
|
{"and": conditions},
|
||||||
|
{"var": ""},
|
||||||
|
False
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
search_results = search_jsonlogic(query)
|
||||||
|
valid_results = [r for r in search_results if isinstance(r.get("result"), dict)]
|
||||||
|
|
||||||
|
valid_results.sort(
|
||||||
|
key=lambda x: x.get("result", {}).get("stat", {}).get("mtime", 0),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if max_count is not None:
|
||||||
|
valid_results = valid_results[:max_count]
|
||||||
|
|
||||||
|
files_data = []
|
||||||
|
for res in valid_results:
|
||||||
|
note_json = res.get("result", {})
|
||||||
|
file_path = res.get("filename", "")
|
||||||
|
files_data.append({
|
||||||
|
"path": file_path,
|
||||||
|
"name": file_path.split('/')[-1].replace('.md', ''),
|
||||||
|
"frontmatter": note_json.get("frontmatter", {}) or {},
|
||||||
|
"body": note_json.get("content", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
return files_data
|
||||||
|
|
||||||
|
# ----------------------------------------------- Main ------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print("Проверка get_recent_files...")
|
||||||
|
# Ищем файлы в папке Фракции с тегом faction
|
||||||
|
files = get_recent_files("Фракции", tags=["faction"], max_count=3)
|
||||||
|
print(f"Найдено файлов: {len(files)}")
|
||||||
|
for f in files:
|
||||||
|
print(f"Путь: {f['path']}")
|
||||||
|
print(f"Фронтматтер keys: {list(f['frontmatter'].keys())}")
|
||||||
Loading…
Reference in New Issue
Block a user