diff --git a/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp b/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp index bbb8a5a1..8ef10d89 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace pqxx { class connection; } namespace merak::kg { class KnowledgeGraphProvider; } @@ -30,6 +31,9 @@ class ConditionEvaluator { // Knowledge Graph integration void set_kg_provider(merak::kg::KnowledgeGraphProvider* provider) { kg_provider_ = provider; } + // File system base directory for scene narrative checks + void set_worlds_base_dir(std::filesystem::path path) { worlds_base_dir_ = std::move(path); } + // Register all built-in condition types and checks (public, callable from tests) void register_all_builtins(); @@ -65,6 +69,7 @@ class ConditionEvaluator { std::map registry_; std::map check_registry_; merak::kg::KnowledgeGraphProvider* kg_provider_ = nullptr; + std::filesystem::path worlds_base_dir_; mutable Stats stats_; }; @@ -110,10 +115,6 @@ ConditionResult eval_orphaned_foreshadowing(const ConditionDef& cond, const PipelineState& state, pqxx::connection& conn); -ConditionResult eval_scene_completeness(const ConditionDef& cond, - const PipelineState& state, - pqxx::connection& conn); - ConditionResult eval_character_consistency(const ConditionDef& cond, const PipelineState& state, pqxx::connection& conn); diff --git a/libs/worldbuilding/src/condition_evaluator.cpp b/libs/worldbuilding/src/condition_evaluator.cpp index bb82bd7e..9169a9f4 100644 --- a/libs/worldbuilding/src/condition_evaluator.cpp +++ b/libs/worldbuilding/src/condition_evaluator.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -104,7 +105,11 @@ void ConditionEvaluator::register_all_builtins() { registry_["diary_completeness"] = eval_diary_completeness; registry_["relation_currency"] = eval_relation_currency; registry_["orphaned_foreshadowing"] = eval_orphaned_foreshadowing; - registry_["scene_completeness"] = eval_scene_completeness; + registry_["scene_completeness"] = [this](const ConditionDef& cond, + const PipelineState& state, + pqxx::connection& conn) -> ConditionResult { + return eval_scene_completeness(cond, state, conn, worlds_base_dir_); + }; // ─── KG condition types ─── registry_["kg_relation_count"] = [this](const ConditionDef& cond, @@ -165,7 +170,11 @@ void ConditionEvaluator::register_all_builtins() { check_registry_["character_consistency"] = eval_character_consistency; check_registry_["diary_completeness"] = eval_diary_completeness; check_registry_["relation_currency"] = eval_relation_currency; - check_registry_["scene_completeness"] = eval_scene_completeness; + check_registry_["scene_completeness"] = [this](const ConditionDef& cond, + const PipelineState& state, + pqxx::connection& conn) -> ConditionResult { + return eval_scene_completeness(cond, state, conn, worlds_base_dir_); + }; } void ConditionEvaluator::register_condition(const std::string& type, ConditionEvalFn fn) { @@ -513,12 +522,22 @@ ConditionResult eval_user_confirmed(const ConditionDef& cond, return result; } +// ─── Helper: scene JSON path ────────────────────────────────────────── +namespace { +std::filesystem::path scene_json_path(const std::filesystem::path& worlds_base_dir, + const std::string& world_id, + const std::string& scene_id) { + return worlds_base_dir / world_id / "scenes" / (scene_id + ".json"); +} +} // namespace + // ═══════════════════════════════════════════════════════════════ -// eval_scene_completeness +// eval_scene_completeness — checks filesystem for non-empty narrative // ═══════════════════════════════════════════════════════════════ ConditionResult eval_scene_completeness(const ConditionDef& cond, const PipelineState& state, - pqxx::connection& conn) { + pqxx::connection& conn, + const std::filesystem::path& worlds_base_dir) { ConditionResult result{cond.message, false, std::nullopt, 0, {}}; if (!state.active_chapter_id) { result.met = false; @@ -527,18 +546,44 @@ ConditionResult eval_scene_completeness(const ConditionDef& cond, } try { pqxx::read_transaction txn(conn); + + // Count draft scenes auto draft_row = txn.exec_params1(R"( SELECT COUNT(*) FROM scenes WHERE chapter_id = $1 AND status = 'draft' )", *state.active_chapter_id); int draft_count = draft_row[0].as(); - auto empty_row = txn.exec_params1(R"( - SELECT COUNT(*) FROM scenes + // Get completed scenes and check narrative in JSON files + auto completed_rows = txn.exec_params(R"( + SELECT id FROM scenes WHERE chapter_id = $1 AND status = 'completed' - AND (narrative IS NULL OR narrative = '') )", *state.active_chapter_id); - int empty_narrative = empty_row[0].as(); + + int empty_narrative = 0; + for (auto const& row : completed_rows) { + auto scene_id = row["id"].as(); + auto path = scene_json_path(worlds_base_dir, state.world_id, scene_id); + if (!std::filesystem::exists(path)) { + empty_narrative++; + continue; + } + std::ifstream f(path); + if (!f.is_open()) { + empty_narrative++; + continue; + } + auto json = nlohmann::json::parse(f, nullptr, false); + if (json.is_discarded()) { + empty_narrative++; + continue; + } + auto it = json.find("narrative"); + if (it == json.end() || it->is_null() || + (it->is_string() && it->get_ref().empty())) { + empty_narrative++; + } + } int total_incomplete = draft_count + empty_narrative; result.current = total_incomplete; @@ -614,7 +659,7 @@ ConditionResult eval_relation_currency(const ConditionDef& cond, auto row = txn.exec_params1(R"( SELECT COUNT(*) FROM agent_relations ar - JOIN agents a ON (ar.agent_a_id = a.id OR ar.agent_b_id = a.id) + JOIN agents a ON (ar.agent_id = a.id OR ar.target_id = a.id) WHERE a.world_id = $1 AND ar.updated_at < $2 )", state.world_id, chapter_start); int stale = row[0].as(); diff --git a/libs/worldbuilding/src/narrative_store.cpp b/libs/worldbuilding/src/narrative_store.cpp index 201a76b4..9db17999 100644 --- a/libs/worldbuilding/src/narrative_store.cpp +++ b/libs/worldbuilding/src/narrative_store.cpp @@ -670,8 +670,6 @@ bool NarrativeStore::patch_scene(const std::string& world_id, if (fields.contains("foreshadowing_ids")) json["foreshadowing_ids"] = fields["foreshadowing_ids"]; if (fields.contains("style_overrides")) json["style_overrides"] = fields["style_overrides"]; - write_json(path, json); - // Build dynamic SET clause for DB update std::vector set_parts; std::vector params; @@ -707,20 +705,26 @@ bool NarrativeStore::patch_scene(const std::string& world_id, params.push_back(fields["plot_goal"].get()); } - if (set_parts.empty()) return true; + // Step 1: DB update first (transactional) + if (!set_parts.empty()) { + std::string sql = "UPDATE scenes SET "; + for (size_t i = 0; i < set_parts.size(); i++) { + if (i > 0) sql += ", "; + sql += set_parts[i]; + } + sql += ", updated_at = NOW() WHERE id = $" + std::to_string(param_idx++) + + " AND world_id = $" + std::to_string(param_idx++); + params.push_back(scene_id); + params.push_back(world_id); - std::string sql = "UPDATE scenes SET "; - for (size_t i = 0; i < set_parts.size(); i++) { - if (i > 0) sql += ", "; - sql += set_parts[i]; + PgConn conn(*pool_); + conn.query(sql, params); } - sql += ", updated_at = NOW() WHERE id = $" + std::to_string(param_idx++) + - " AND world_id = $" + std::to_string(param_idx++); - params.push_back(scene_id); - params.push_back(world_id); - PgConn conn(*pool_); - conn.query(sql, params); + // Step 2: Write JSON file atomically (only after DB succeeds) + auto tmp_path = path.string() + ".tmp"; + write_json(tmp_path, json); + std::filesystem::rename(tmp_path, path); return true; } @@ -743,8 +747,6 @@ bool NarrativeStore::patch_chapter(const std::string& world_id, if (fields.contains("number")) json["number"] = fields["number"]; if (fields.contains("status")) json["status"] = fields["status"]; - write_json(path, json); - // Build dynamic SET clause for DB update std::vector set_parts; std::vector params; @@ -775,20 +777,26 @@ bool NarrativeStore::patch_chapter(const std::string& world_id, } } - if (set_parts.empty()) return true; + // Step 1: DB update first (transactional) + if (!set_parts.empty()) { + std::string sql = "UPDATE chapters SET "; + for (size_t i = 0; i < set_parts.size(); i++) { + if (i > 0) sql += ", "; + sql += set_parts[i]; + } + sql += ", updated_at = NOW() WHERE id = $" + std::to_string(param_idx++) + + " AND world_id = $" + std::to_string(param_idx++); + params.push_back(chapter_id); + params.push_back(world_id); - std::string sql = "UPDATE chapters SET "; - for (size_t i = 0; i < set_parts.size(); i++) { - if (i > 0) sql += ", "; - sql += set_parts[i]; + PgConn conn(*pool_); + conn.query(sql, params); } - sql += ", updated_at = NOW() WHERE id = $" + std::to_string(param_idx++) + - " AND world_id = $" + std::to_string(param_idx++); - params.push_back(chapter_id); - params.push_back(world_id); - PgConn conn(*pool_); - conn.query(sql, params); + // Step 2: Write JSON file atomically (only after DB succeeds) + auto tmp_path = path.string() + ".tmp"; + write_json(tmp_path, json); + std::filesystem::rename(tmp_path, path); return true; } diff --git a/libs/worldbuilding/src/pipeline_manager.cpp b/libs/worldbuilding/src/pipeline_manager.cpp index ac2d1f6a..f886387c 100644 --- a/libs/worldbuilding/src/pipeline_manager.cpp +++ b/libs/worldbuilding/src/pipeline_manager.cpp @@ -66,6 +66,9 @@ PipelineManager::PipelineManager(Dependencies deps) : deps_(std::move(deps)) { if (!deps_.pg_connection_factory) { throw std::invalid_argument("PipelineManager: pg_connection_factory is required"); } + if (!deps_.worlds_base_dir.empty()) { + deps_.condition_evaluator->set_worlds_base_dir(deps_.worlds_base_dir); + } start_time_ = std::chrono::steady_clock::now(); } PipelineManager::~PipelineManager() = default; diff --git a/libs/worldbuilding/src/worldbuilding_service.cpp b/libs/worldbuilding/src/worldbuilding_service.cpp index 57fddf36..5ec91fae 100644 --- a/libs/worldbuilding/src/worldbuilding_service.cpp +++ b/libs/worldbuilding/src/worldbuilding_service.cpp @@ -365,7 +365,6 @@ nlohmann::json WorldbuildingService::resolve_creation( return {{"ok", true}, {"decision", "deny"}, {"creation_id", creation_id}}; } - delete_pending_creation(creation_id); // Step 2: Merge modifications for "modify" nlohmann::json final_params = pc.params; if (decision == "modify" && !modifications.is_null()) { @@ -487,13 +486,12 @@ nlohmann::json WorldbuildingService::resolve_creation( throw std::runtime_error("Unknown tool_name: " + pc.tool_name); } } catch (...) { - // Step 4: On failure, re-insert pc into map and PostgreSQL for retry. - persist_pending_creation(pc); - std::lock_guard lock(pending_mutex_); - pending_creations_[creation_id] = std::move(pc); throw; } + // Only delete pending creation after entity creation succeeds + delete_pending_creation(creation_id); + return result; }