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:
2026-05-03 14:41:28 +02:00
parent 4be5734ce5
commit 7a94160fd2
26 changed files with 973 additions and 241 deletions
+273 -20
View File
@@ -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;
}