Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <shared_mutex>
#include <string>
#include <vector>
#include <filesystem>

namespace pqxx { class connection; }
namespace merak::kg { class KnowledgeGraphProvider; }
Expand All @@ -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();

Expand Down Expand Up @@ -65,6 +69,7 @@ class ConditionEvaluator {
std::map<std::string, ConditionEvalFn> registry_;
std::map<std::string, ConditionEvalFn> check_registry_;
merak::kg::KnowledgeGraphProvider* kg_provider_ = nullptr;
std::filesystem::path worlds_base_dir_;
mutable Stats stats_;
};

Expand Down Expand Up @@ -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);
Expand Down
63 changes: 54 additions & 9 deletions libs/worldbuilding/src/condition_evaluator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <pqxx/pqxx>
#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include <set>
#include <sstream>

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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<int>();

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>();

int empty_narrative = 0;
for (auto const& row : completed_rows) {
auto scene_id = row["id"].as<std::string>();
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<const std::string&>().empty())) {
empty_narrative++;
}
}

int total_incomplete = draft_count + empty_narrative;
result.current = total_incomplete;
Expand Down Expand Up @@ -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<int>();
Expand Down
60 changes: 34 additions & 26 deletions libs/worldbuilding/src/narrative_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> set_parts;
std::vector<std::string> params;
Expand Down Expand Up @@ -707,20 +705,26 @@ bool NarrativeStore::patch_scene(const std::string& world_id,
params.push_back(fields["plot_goal"].get<std::string>());
}

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;
}
Expand All @@ -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<std::string> set_parts;
std::vector<std::string> params;
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions libs/worldbuilding/src/pipeline_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 3 additions & 5 deletions libs/worldbuilding/src/worldbuilding_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
}

Expand Down
Loading