feat: catch-up de decisiones previas (Webpage→Url, anti-bot, UI 2-col, tests cross-platform)
Bloque de cambios revisados y validados con el usuario en sesiones previas que no habian aterrizado en commits propios. Lista por tema: * enrichers: web_search ahora usa lite.duckduckgo.com como endpoint primario (mas tolerante con bot detection desde IP residencial), con fallback al endpoint html. Detecta pagina captcha y emite error claro si ambos fallan. Anyade _DDGLiteParser para el formato lite + auto-pick de parser por contenido. * enrichers: tipo Webpage unificado en Url (campos de cuerpo cacheado viven en metadata del Url). Manifests actualizados (applies_to: [Url]). fetch_webpage ya no convierte Url->Webpage. * enrichers/manifest: campo `params` parseado a EnricherSpec.params (name, type, default_value, description). UI puede renderizar dialog de configuracion. * jobs: fix de path conversion para Python embebido nativo Windows (no convertir a /mnt/c/... cuando el subproceso es Windows-native; solo cuando es bash o python via WSL). * main.cpp: ventana ImGui (no modal) "Run enricher" con layout 2-col (label izq, input der). Inserta job con JSON tipado. Layout clustering apretado: hijos del mismo anchor en un solo anillo alrededor del padre, sin desperdigar por anillos crecientes. * views: inspector con layout 2-col via BeginTable (Identity, Schema fields, Extras). Description full-width debajo de su label. * tests: portable conftest (auto-detecta REGISTRY_ROOT, PYTHON_BIN, ENRICHERS_DIR para WSL y Windows portable). _runner.py trampoline inyecta stub via sys.path porque embedded Python ignora PYTHONPATH. Tests bash-only (vendor_script, freeze, dispatcher bash, resolver Linux-binary) skipean en Windows. Tests existentes adaptados a Webpage->Url. Resultado actual: 32 passed WSL, 21 passed + 11 skipped Windows.
This commit is contained in:
@@ -43,6 +43,8 @@
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#ifndef _WIN32
|
||||
@@ -318,27 +320,91 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
|
||||
int park_n = 0;
|
||||
|
||||
int placed_neighbor = 0, placed_camera = 0, parked = 0;
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
GraphNode& n = g.nodes[i];
|
||||
if (n.x != 0.0f || n.y != 0.0f) continue;
|
||||
|
||||
// ----- Pase 1: agrupar orphans por su anchor (vecino con posicion) -----
|
||||
// Cuando un enricher crea N nodos todos conectados al mismo source
|
||||
// (caso tipico: web_search → N Urls SEARCH_RESULT_OF source), queremos
|
||||
// que los N nodos clustereen MUY apretados alrededor del source en
|
||||
// un solo anillo, no que se desperdiguen por anillos concentricos
|
||||
// hasta encontrar slot libre. La busqueda anti-colision individual
|
||||
// los empuja hacia fuera cuando ya hay vecinos preexistentes; aqui
|
||||
// les damos a los hermanos del mismo anchor angulos repartidos en
|
||||
// un anillo unico cerca del padre.
|
||||
std::unordered_map<int, std::vector<int>> orphans_by_anchor;
|
||||
std::vector<int> orphans_no_anchor;
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
const GraphNode& n = g.nodes[i];
|
||||
if (n.x != 0.0f || n.y != 0.0f) continue;
|
||||
int parent = layout_first_placed_neighbor(g, i);
|
||||
if (parent >= 0) {
|
||||
float ox, oy;
|
||||
if (find_collision_free_slot(
|
||||
g, i, g.nodes[parent].x, g.nodes[parent].y,
|
||||
min_dist, n.user_data,
|
||||
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
|
||||
n.x = ox; n.y = oy;
|
||||
} else {
|
||||
// Acepta solape como ultimo recurso.
|
||||
n.x = g.nodes[parent].x + neighbor_radii[n_neighbor_radii - 1];
|
||||
n.y = g.nodes[parent].y;
|
||||
if (parent >= 0) orphans_by_anchor[parent].push_back(i);
|
||||
else orphans_no_anchor.push_back(i);
|
||||
}
|
||||
|
||||
// ----- Pase 2: place clusters (orphans con anchor) -----
|
||||
// Para cada anchor con sus hijos, los repartimos en un anillo
|
||||
// alrededor del padre. Si hay mas hijos de los que caben en el
|
||||
// anillo base, abrimos anillos adicionales. Cada hijo sigue
|
||||
// pasando find_collision_free_slot como fallback si el slot ideal
|
||||
// estaba ocupado por otro nodo del grafo.
|
||||
const float two_pi = 6.28318530718f;
|
||||
for (auto& kv : orphans_by_anchor) {
|
||||
int parent = kv.first;
|
||||
std::vector<int>& kids = kv.second;
|
||||
if (kids.empty()) continue;
|
||||
// Orden estable por user_data para que rondas sucesivas del
|
||||
// mismo enricher (mismo set de hijos) coloquen igual.
|
||||
std::sort(kids.begin(), kids.end(),
|
||||
[&](int a, int b) {
|
||||
return g.nodes[a].user_data < g.nodes[b].user_data;
|
||||
});
|
||||
float cx = g.nodes[parent].x;
|
||||
float cy = g.nodes[parent].y;
|
||||
// Capacidad por anillo: circunferencia / min_dist.
|
||||
// Para min_dist=60, ring r=80 -> ~8 slots; r=140 -> ~14.
|
||||
for (size_t k = 0; k < kids.size(); ++k) {
|
||||
// Anillo y slot dentro del anillo en funcion del indice.
|
||||
int ri = 0; size_t accum = 0; size_t cap = 0;
|
||||
for (; ri < n_neighbor_radii; ++ri) {
|
||||
float r_here = neighbor_radii[ri];
|
||||
cap = (size_t)std::max(6.0f, two_pi * r_here / min_dist);
|
||||
if (k < accum + cap) break;
|
||||
accum += cap;
|
||||
}
|
||||
n.vx = n.vy = 0.0f;
|
||||
if (ri >= n_neighbor_radii) ri = n_neighbor_radii - 1;
|
||||
float r_use = neighbor_radii[ri];
|
||||
cap = (size_t)std::max(6.0f, two_pi * r_use / min_dist);
|
||||
size_t slot = k - accum;
|
||||
// Jitter pequeno por user_data para que rondas distintas no
|
||||
// queden alineadas si comparten anchor.
|
||||
uint64_t seed = g.nodes[kids[k]].user_data;
|
||||
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * (two_pi / cap);
|
||||
float angle = jitter + (float)slot * (two_pi / cap);
|
||||
float px = cx + r_use * std::cos(angle);
|
||||
float py = cy + r_use * std::sin(angle);
|
||||
// Si el slot ideal colisiona con un nodo ajeno al cluster,
|
||||
// delegamos en find_collision_free_slot que probara mas
|
||||
// angulos en radios crecientes.
|
||||
GraphNode& kid = g.nodes[kids[k]];
|
||||
if (layout_no_collision(g, kids[k], px, py, min_dist)) {
|
||||
kid.x = px; kid.y = py;
|
||||
} else {
|
||||
float ox, oy;
|
||||
if (find_collision_free_slot(
|
||||
g, kids[k], cx, cy, min_dist, seed,
|
||||
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
|
||||
kid.x = ox; kid.y = oy;
|
||||
} else {
|
||||
kid.x = px; kid.y = py; // ultimo recurso: solape
|
||||
}
|
||||
}
|
||||
kid.vx = kid.vy = 0.0f;
|
||||
++placed_neighbor;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Pase 3: place orphans sin anchor (camera o parking lot) -----
|
||||
for (int i : orphans_no_anchor) {
|
||||
GraphNode& n = g.nodes[i];
|
||||
|
||||
if (use_camera) {
|
||||
// Sin vecino → colocar dentro de la camara con ring placement.
|
||||
@@ -875,10 +941,29 @@ static void render_context_menu() {
|
||||
} else {
|
||||
for (const auto& s : specs) {
|
||||
if (ImGui::MenuItem(s.name.c_str())) {
|
||||
char job_id[64];
|
||||
bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl,
|
||||
"{}", job_id, sizeof(job_id));
|
||||
if (ok) g_app.panel_jobs = true;
|
||||
if (s.params.empty()) {
|
||||
// Sin params editables: submit directo, comportamiento
|
||||
// historico — un click y a correr.
|
||||
char job_id[64];
|
||||
bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl,
|
||||
"{}", job_id, sizeof(job_id));
|
||||
if (ok) g_app.panel_jobs = true;
|
||||
} else {
|
||||
// Abrir ventana de configuracion. Inicializar
|
||||
// buffers con los defaults del manifest.
|
||||
g_app.enr_modal_id = s.id;
|
||||
g_app.enr_modal_node_id = sql_id;
|
||||
g_app.enr_modal_node_label = lbl ? lbl : "";
|
||||
g_app.enr_modal_param_bufs.clear();
|
||||
g_app.enr_modal_param_bufs.resize(s.params.size());
|
||||
for (size_t i = 0; i < s.params.size(); ++i) {
|
||||
const std::string& dv = s.params[i].default_value;
|
||||
auto& buf = g_app.enr_modal_param_bufs[i];
|
||||
buf.assign(256, '\0');
|
||||
std::snprintf(buf.data(), buf.size(), "%s", dv.c_str());
|
||||
}
|
||||
g_app.enr_window_open = true;
|
||||
}
|
||||
}
|
||||
if (!s.description.empty() && ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", s.description.c_str());
|
||||
@@ -891,6 +976,171 @@ static void render_context_menu() {
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Modal: configurar parametros de enricher antes de lanzar el job
|
||||
// ----------------------------------------------------------------------------
|
||||
// Se invoca desde el context menu (Run enricher → click). Si el enricher
|
||||
// declara `params` en su manifest, en lugar de submitear directamente,
|
||||
// llenamos el AppState (ver bloque `enr_modal_*`) y aqui renderizamos el
|
||||
// dialogo. El usuario ajusta valores y al pulsar Run construimos el
|
||||
// JSON `{ "param": value, ... }` y lo pasamos a `jobs_submit`.
|
||||
|
||||
static std::string json_escape_str(const std::string& s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if ((unsigned char)c < 0x20) {
|
||||
char b[8];
|
||||
std::snprintf(b, sizeof(b), "\\u%04x", (unsigned char)c);
|
||||
out += b;
|
||||
} else {
|
||||
out.push_back(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Renderiza una fila label/input dentro de una BeginTable de 2 columnas.
|
||||
// El label va a la izquierda alineado al frame del input; el input usa
|
||||
// todo el ancho disponible de la columna derecha.
|
||||
static void labeled_row_begin(const char* label) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(label);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
}
|
||||
|
||||
static void render_enricher_config_window() {
|
||||
if (!g_app.enr_window_open) return;
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver);
|
||||
if (!ImGui::Begin("Run enricher", &g_app.enr_window_open,
|
||||
ImGuiWindowFlags_NoCollapse)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
const ge::EnricherSpec* spec = ge::enricher_by_id(g_app.enr_modal_id.c_str());
|
||||
if (!spec) {
|
||||
ImGui::TextDisabled("(enricher no encontrado)");
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::Text("%s", spec->name.c_str());
|
||||
if (!spec->description.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.7f, 0.7f, 0.7f, 1.0f));
|
||||
ImGui::TextWrapped("%s", spec->description.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::Separator();
|
||||
ImGui::TextDisabled("Node: %s", g_app.enr_modal_node_label.c_str());
|
||||
ImGui::Spacing();
|
||||
|
||||
// Asegurar tamaño de buffers — un manifest puede haberse recargado
|
||||
// con mas params de los que llenamos al abrir la ventana.
|
||||
if (g_app.enr_modal_param_bufs.size() < spec->params.size()) {
|
||||
g_app.enr_modal_param_bufs.resize(spec->params.size());
|
||||
}
|
||||
|
||||
if (ImGui::BeginTable("##enr_params", 2,
|
||||
ImGuiTableFlags_SizingStretchProp |
|
||||
ImGuiTableFlags_NoBordersInBody)) {
|
||||
ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthFixed, 110.0f);
|
||||
ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch);
|
||||
|
||||
for (size_t i = 0; i < spec->params.size(); ++i) {
|
||||
const auto& p = spec->params[i];
|
||||
auto& buf = g_app.enr_modal_param_bufs[i];
|
||||
if (buf.size() < 256) buf.resize(256, '\0');
|
||||
|
||||
ImGui::PushID((int)i);
|
||||
labeled_row_begin(p.name.c_str());
|
||||
const std::string& t = p.type;
|
||||
if (t == "int") {
|
||||
int v = std::atoi(buf.data());
|
||||
if (ImGui::InputInt("##v", &v, 1, 10)) {
|
||||
std::snprintf(buf.data(), buf.size(), "%d", v);
|
||||
}
|
||||
} else if (t == "float" || t == "double" || t == "number") {
|
||||
float v = (float)std::atof(buf.data());
|
||||
if (ImGui::InputFloat("##v", &v)) {
|
||||
std::snprintf(buf.data(), buf.size(), "%g", v);
|
||||
}
|
||||
} else if (t == "bool") {
|
||||
bool v = (std::strcmp(buf.data(), "true") == 0 ||
|
||||
std::strcmp(buf.data(), "1") == 0);
|
||||
if (ImGui::Checkbox("##v", &v)) {
|
||||
std::snprintf(buf.data(), buf.size(), "%s", v ? "true" : "false");
|
||||
}
|
||||
} else {
|
||||
ImGui::InputText("##v", buf.data(), buf.size());
|
||||
}
|
||||
if (!p.description.empty() && ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", p.description.c_str());
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("Run", ImVec2(100, 0))) {
|
||||
// Construir JSON `{ "name": value, ... }` segun los tipos.
|
||||
std::string j = "{";
|
||||
for (size_t i = 0; i < spec->params.size(); ++i) {
|
||||
const auto& p = spec->params[i];
|
||||
const auto& buf = g_app.enr_modal_param_bufs[i];
|
||||
if (i) j += ",";
|
||||
j += "\"";
|
||||
j += json_escape_str(p.name);
|
||||
j += "\":";
|
||||
if (p.type == "int") {
|
||||
int v = std::atoi(buf.data());
|
||||
char b[32]; std::snprintf(b, sizeof(b), "%d", v);
|
||||
j += b;
|
||||
} else if (p.type == "float" || p.type == "double" || p.type == "number") {
|
||||
double v = std::atof(buf.data());
|
||||
char b[64]; std::snprintf(b, sizeof(b), "%g", v);
|
||||
j += b;
|
||||
} else if (p.type == "bool") {
|
||||
bool v = (std::strcmp(buf.data(), "true") == 0 ||
|
||||
std::strcmp(buf.data(), "1") == 0);
|
||||
j += v ? "true" : "false";
|
||||
} else {
|
||||
j += "\"";
|
||||
j += json_escape_str(buf.data());
|
||||
j += "\"";
|
||||
}
|
||||
}
|
||||
j += "}";
|
||||
|
||||
char job_id[64];
|
||||
bool ok = ge::jobs_submit(spec->id.c_str(),
|
||||
g_app.enr_modal_node_id.c_str(),
|
||||
g_app.enr_modal_node_label.c_str(),
|
||||
j.c_str(), job_id, sizeof(job_id));
|
||||
if (ok) g_app.panel_jobs = true;
|
||||
g_app.enr_window_open = false;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel", ImVec2(100, 0))) {
|
||||
g_app.enr_window_open = false;
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Label callback
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -1742,6 +1992,9 @@ static void render() {
|
||||
ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
|
||||
ge::chat_render(&g_app.panel_chat);
|
||||
|
||||
// Enricher config window (abierto desde context menu Run enricher).
|
||||
render_enricher_config_window();
|
||||
|
||||
g_first_render = false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user