From 3381aeaf96b8a7169f55feb980d5ed6c7aaa1bca Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:02:31 +0000 Subject: [PATCH 1/6] fix: use correct column names in eval_relation_currency (agent_id/target_id) agent_relations table uses agent_id and target_id, not agent_a_id/agent_b_id. SQL error at runtime prevented relation_currency condition from evaluating. --- libs/worldbuilding/src/condition_evaluator.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/worldbuilding/src/condition_evaluator.cpp b/libs/worldbuilding/src/condition_evaluator.cpp index bb82bd7e..74c4a842 100644 --- a/libs/worldbuilding/src/condition_evaluator.cpp +++ b/libs/worldbuilding/src/condition_evaluator.cpp @@ -614,7 +614,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(); From 3b5095c6c73766821b131b99b9351f7e50bb9f25 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:03:14 +0000 Subject: [PATCH 2/6] feat: add worlds_base_dir injection to ConditionEvaluator Needed by eval_scene_completeness for file-system-based narrative checks. --- .../include/merak/worldbuilding/condition_evaluator.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp b/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp index bbb8a5a1..c372a303 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_; }; From 414a59a878f9e93a6676203efe9ca1e070f1fbda Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:04:13 +0000 Subject: [PATCH 3/6] fix: eval_scene_completeness check narrative via filesystem instead of DB column The scenes table has no narrative column; narrative content lives in JSON files. Querying a non-existent column caused a runtime SQL error. Now reads scene JSON files to verify narrative is non-empty, using worlds_base_dir for path resolution. --- .../worldbuilding/condition_evaluator.hpp | 4 -- .../worldbuilding/src/condition_evaluator.cpp | 61 ++++++++++++++++--- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp b/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp index c372a303..8ef10d89 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/condition_evaluator.hpp @@ -115,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 74c4a842..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; From 68ae6b7d12c579798a7cd839b0814edf9b7bc942 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:04:58 +0000 Subject: [PATCH 4/6] feat: inject worlds_base_dir into ConditionEvaluator from PipelineManager --- libs/worldbuilding/src/pipeline_manager.cpp | 3 +++ 1 file changed, 3 insertions(+) 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; From 72271d64c3843853c7d32224e0782acd8a5879e2 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:06:19 +0000 Subject: [PATCH 5/6] fix: write DB first then file atomically in patch_scene/patch_chapter Previously, JSON files were written before DB updates. If the DB update failed, the filesystem was already modified causing persistent inconsistency. Now DB writes happen first, and file writes use tmp + rename for atomicity. --- libs/worldbuilding/src/narrative_store.cpp | 60 ++++++++++++---------- 1 file changed, 34 insertions(+), 26 deletions(-) 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; } From d97689d7b61905ab539098f0d6348824846a34be Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 18:07:52 +0000 Subject: [PATCH 6/6] fix: delete pending creation only after entity creation succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, delete_pending_creation ran before entity creation. If creation failed, a concurrent caller could observe the pending creation as missing during the gap between delete and re-insert. Now deletion happens only after successful creation — no recovery logic needed on failure. --- libs/worldbuilding/src/worldbuilding_service.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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; }