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
+87 -8
View File
@@ -59,6 +59,57 @@ std::vector<std::string> parse_inline_list(const std::string& v) {
return out; return out;
} }
// Split por comas a nivel cero, respetando comillas y nesting de [] / {}.
// El YAML inline `{ name: limit, type: int, default: 10 }` puede contener
// strings con comas entre comillas — un split crudo las rompería.
std::vector<std::string> split_top_level(const std::string& s) {
std::vector<std::string> out;
std::string cur;
int depth_b = 0, depth_c = 0;
char quote = 0;
for (char c : s) {
if (quote) {
cur.push_back(c);
if (c == quote) quote = 0;
continue;
}
if (c == '"' || c == '\'') { quote = c; cur.push_back(c); continue; }
if (c == '[') ++depth_b;
if (c == ']') --depth_b;
if (c == '{') ++depth_c;
if (c == '}') --depth_c;
if (c == ',' && depth_b == 0 && depth_c == 0) {
out.push_back(cur);
cur.clear();
continue;
}
cur.push_back(c);
}
if (!cur.empty()) out.push_back(cur);
return out;
}
// Parsea un objeto YAML inline `{ name: x, type: int, default: 10 }` a un
// EnricherParam. Retorna true si al menos `name` se resolvio.
bool parse_inline_param(const std::string& v, EnricherParam* out) {
std::string s = strip(v);
if (s.size() < 2 || s.front() != '{' || s.back() != '}') return false;
s = s.substr(1, s.size() - 2);
for (auto& kv : split_top_level(s)) {
size_t colon = kv.find(':');
if (colon == std::string::npos) continue;
std::string k = strip(kv.substr(0, colon));
std::string val = strip_quotes(strip(kv.substr(colon + 1)));
if (k == "name") out->name = val;
else if (k == "type") out->type = lower(val);
else if (k == "default") out->default_value = val;
else if (k == "description") out->description = val;
else if (k == "desc") out->description = val;
}
if (out->type.empty()) out->type = "string";
return !out->name.empty();
}
// Manifest YAML soportado (subset): // Manifest YAML soportado (subset):
// id: fetch_webpage // id: fetch_webpage
// name: "Fetch web page" // name: "Fetch web page"
@@ -66,16 +117,19 @@ std::vector<std::string> parse_inline_list(const std::string& v) {
// applies_to: [Webpage, Url] // applies_to: [Webpage, Url]
// lang: python <- issue 0033: go|python|bash (default python) // lang: python <- issue 0033: go|python|bash (default python)
// exec: run <- basename del binario/script (default "run") // exec: run <- basename del binario/script (default "run")
// params: <- v1 ignora bloque // params:
// - { name: timeout_s, ... } // - { name: timeout_s, type: int, default: 15 }
// - { name: region, type: string, default: "" }
// //
// Las claves anidadas bajo `params:` (y otros bloques con valor vacio // Solo el bloque `params:` se parsea con detalle. Otros bloques con valor
// seguido de lineas indentadas) se ignoran. // vacio seguido de lineas indentadas (`emits:`, `relations:`,
// `uses_functions:`) se ignoran como antes.
bool parse_manifest(const std::string& path, EnricherSpec* out) { bool parse_manifest(const std::string& path, EnricherSpec* out) {
std::ifstream f(path); std::ifstream f(path);
if (!f) return false; if (!f) return false;
std::string line; std::string line;
bool in_skip_block = false; bool in_skip_block = false;
bool in_params_block = false;
while (std::getline(f, line)) { while (std::getline(f, line)) {
// Strip CR de Windows. // Strip CR de Windows.
if (!line.empty() && line.back() == '\r') line.pop_back(); if (!line.empty() && line.back() == '\r') line.pop_back();
@@ -84,10 +138,27 @@ bool parse_manifest(const std::string& path, EnricherSpec* out) {
std::string trim = strip(line); std::string trim = strip(line);
if (trim.empty() || trim.front() == '#') continue; if (trim.empty() || trim.front() == '#') continue;
// Si la linea NO empieza con whitespace, salimos del bloque skip. // Si la linea NO empieza con whitespace, salimos de los bloques
// anidados — el siguiente top-level reinicia el contexto.
bool indented = !line.empty() && std::isspace((unsigned char)line.front()); bool indented = !line.empty() && std::isspace((unsigned char)line.front());
if (!indented) in_skip_block = false; if (!indented) {
in_skip_block = false;
in_params_block = false;
}
if (in_skip_block) continue; if (in_skip_block) continue;
if (in_params_block) {
// Linea esperada: ` - { name: x, type: int, default: 10 }`.
// Tolera variaciones de indent y comilla.
std::string body = trim;
if (!body.empty() && body.front() == '-') {
body = strip(body.substr(1));
}
EnricherParam p;
if (parse_inline_param(body, &p)) {
out->params.push_back(std::move(p));
}
continue;
}
size_t colon = trim.find(':'); size_t colon = trim.find(':');
if (colon == std::string::npos) continue; if (colon == std::string::npos) continue;
@@ -101,9 +172,17 @@ bool parse_manifest(const std::string& path, EnricherSpec* out) {
else if (key == "applies_to") out->applies_to = parse_inline_list(val); else if (key == "applies_to") out->applies_to = parse_inline_list(val);
else if (key == "lang") out->lang = lower(strip_quotes(val)); else if (key == "lang") out->lang = lower(strip_quotes(val));
else if (key == "exec") out->exec_basename = strip_quotes(val); else if (key == "exec") out->exec_basename = strip_quotes(val);
else if (key == "params" && val.empty()) in_skip_block = true; else if (key == "params") {
// `params: []` — vacio explicito, nada que hacer.
// `params:` — siguiente bloque indentado son items.
std::string vs = strip(val);
if (vs.empty()) in_params_block = true;
// Si fuese inline (`params: [{...}]`) — formato no usado en
// los manifests actuales, lo ignoramos.
}
else if (key == "emits" && val.empty()) in_skip_block = true; else if (key == "emits" && val.empty()) in_skip_block = true;
else if (key == "relations" && val.empty()) in_skip_block = true; else if (key == "relations" && val.empty()) in_skip_block = true;
else if (key == "uses_functions" && val.empty()) in_skip_block = true;
} }
// Defaults — preservan retrocompat con manifests existentes que no // Defaults — preservan retrocompat con manifests existentes que no
+16 -2
View File
@@ -10,11 +10,22 @@
// `enrichers_for_type(type_ref)` para mostrar el submenu filtrado por tipo // `enrichers_for_type(type_ref)` para mostrar el submenu filtrado por tipo
// del nodo right-clickado. // del nodo right-clickado.
// //
// Para v1 no parseamos `params` con detalle — solo lo necesario para // Los parametros declarados en `params:` del manifest se parsean para que
// presentar el item de menu y submitear el job con `{}`. // la UI pueda renderizar un dialog de configuracion antes de lanzar el
// job. Si la lista esta vacia, el job se submitea directamente con `{}`.
namespace ge { namespace ge {
// Parametro declarado en `manifest.yaml` -> entrada `{ name, type, default }`.
// La UI de configuracion edita un buffer string por param y lo serializa a
// JSON segun el `type` al pulsar Run.
struct EnricherParam {
std::string name; // ej: "limit"
std::string type; // "int" | "float" | "string" | "bool"
std::string default_value; // valor por defecto en formato texto
std::string description; // opcional, para tooltip
};
struct EnricherSpec { struct EnricherSpec {
std::string id; // ej: "fetch_webpage" std::string id; // ej: "fetch_webpage"
std::string name; // ej: "Fetch web page" std::string name; // ej: "Fetch web page"
@@ -34,6 +45,9 @@ struct EnricherSpec {
// <dir>/<exec_basename>{.exe} segun la plataforma. Default "run". // <dir>/<exec_basename>{.exe} segun la plataforma. Default "run".
std::string exec_basename; std::string exec_basename;
// Parametros editables por el usuario antes de lanzar el job.
std::vector<EnricherParam> params;
// True si lang != "" y no se pudo resolver el ejecutable // True si lang != "" y no se pudo resolver el ejecutable
// correspondiente (ej: enricher Go sin compilar). El loader deja // correspondiente (ej: enricher Go sin compilar). El loader deja
// el spec en el registro pero marcado como deshabilitado para // el spec en el registro pero marcado como deshabilitado para
+1 -1
View File
@@ -1,7 +1,7 @@
id: extract_domain id: extract_domain
name: "Extract domain" name: "Extract domain"
description: "Saca el dominio de la url/email del nodo y crea/conecta una entidad Domain con relacion BELONGS_TO. No descarga nada." description: "Saca el dominio de la url/email del nodo y crea/conecta una entidad Domain con relacion BELONGS_TO. No descarga nada."
applies_to: [Url, Webpage, Email] applies_to: [Url, Email]
emits: [Domain] emits: [Domain]
relations: [BELONGS_TO] relations: [BELONGS_TO]
params: [] params: []
+2 -2
View File
@@ -1,7 +1,7 @@
id: extract_links id: extract_links
name: "Extract links" name: "Extract links"
description: "Lee la markdown cacheada de un Webpage (metadata.markdown_path) y crea nodos Url para cada enlace encontrado, conectados con relacion LINKS_TO. Requiere haber ejecutado fetch_webpage antes." description: "Lee la markdown cacheada del nodo Url (metadata.markdown_path) y crea nodos Url para cada enlace encontrado, conectados con relacion LINKS_TO. Requiere haber ejecutado fetch_webpage antes."
applies_to: [Webpage] applies_to: [Url]
emits: [Url] emits: [Url]
relations: [LINKS_TO] relations: [LINKS_TO]
uses_functions: uses_functions:
@@ -1,7 +1,7 @@
id: extract_text_entities id: extract_text_entities
name: "Extract entities from text" name: "Extract entities from text"
description: "Lee la markdown cacheada de un Webpage y extrae IoCs (IPs, emails, dominios, hashes, crypto wallets, CVEs, MAC, telefonos) creando entidades + relacion EXTRACTED_FROM. Sin coste — solo regex. Modelos ML (GLiNER/GLiREL) en futura iteracion." description: "Lee la markdown cacheada de un Url y extrae IoCs (IPs, emails, dominios, hashes, crypto wallets, CVEs, MAC, telefonos) creando entidades + relacion EXTRACTED_FROM. Sin coste — solo regex. Modelos ML (GLiNER/GLiREL) en futura iteracion."
applies_to: [Webpage] applies_to: [Url]
emits: [Email, IPAddress, Domain, FileHash, CryptoWallet, CVE, MACAddress, Phone] emits: [Email, IPAddress, Domain, FileHash, CryptoWallet, CVE, MACAddress, Phone]
relations: [EXTRACTED_FROM] relations: [EXTRACTED_FROM]
uses_functions: uses_functions:
+2 -2
View File
@@ -1,7 +1,7 @@
id: fetch_webpage id: fetch_webpage
name: "Fetch web page" name: "Fetch web page"
description: "Descarga HTML de una URL, extrae markdown limpio (readabilipy) y guarda los blobs en cache. Crea/actualiza el nodo Webpage con title/status_code/paths y crea el Domain con relacion BELONGS_TO." description: "Descarga HTML de una URL, extrae markdown limpio (readabilipy) y guarda los blobs en cache. Actualiza el nodo Url con title/status_code/paths/markdown en metadata y crea el Domain con relacion BELONGS_TO."
applies_to: [Url, Webpage] applies_to: [Url]
emits: [Domain] emits: [Domain]
relations: [BELONGS_TO] relations: [BELONGS_TO]
uses_functions: uses_functions:
+14 -2
View File
@@ -3,7 +3,12 @@
Lee JSON de stdin, descarga la URL del nodo, convierte HTML a markdown, Lee JSON de stdin, descarga la URL del nodo, convierte HTML a markdown,
guarda blobs en `<cache_dir>/<sha256[0:2]>/<sha256>.{html,md}`, actualiza el guarda blobs en `<cache_dir>/<sha256[0:2]>/<sha256>.{html,md}`, actualiza el
nodo a tipo Webpage con metadata enriquecida y crea/conecta el Domain. nodo (deja type_ref=Url) con metadata enriquecida y crea/conecta el Domain.
Nota: historicamente fetch_webpage convertia Url -> Webpage, pero esos
dos tipos se han unificado en Url. Los campos de cuerpo cacheado
(html_path, markdown_path, status_code, fetched_at, text_length, ...)
viven en metadata.
Wire protocol (issue 0026): Wire protocol (issue 0026):
- stdin: JSON con node_id, metadata, ops_db_path, app_dir, cache_dir, - stdin: JSON con node_id, metadata, ops_db_path, app_dir, cache_dir,
@@ -289,7 +294,14 @@ def main() -> int:
log(f"node {node_id} disappeared") log(f"node {node_id} disappeared")
return 6 return 6
cur_type, cur_meta = row[0], row[1] or "{}" cur_type, cur_meta = row[0], row[1] or "{}"
new_type = "Webpage" if cur_type.lower() == "url" else cur_type or "Webpage" # Webpage fue un tipo separado historicamente. Hoy se unifica en
# Url (mismo tipo, los campos de cuerpo cacheado viven en
# metadata): si el nodo entrante es Url o el legacy Webpage, lo
# dejamos como Url; si el nodo no tiene tipo, default Url.
if not cur_type or cur_type.lower() in ("url", "webpage"):
new_type = "Url"
else:
new_type = cur_type
patch = { patch = {
"url": url, "url": url,
Binary file not shown.
+170 -35
View File
@@ -8,14 +8,20 @@ Wire protocol estandar (issue 0026):
- stdout: una linea JSON al final con resumen. - stdout: una linea JSON al final con resumen.
- exit code 0 = ok, !=0 = error. - exit code 0 = ok, !=0 = error.
DDG endpoint usado: https://html.duckduckgo.com/html/?q=<query> DDG endpoints usados:
Devuelve HTML estatico, sin JavaScript. Los enlaces vienen envueltos en 1. https://lite.duckduckgo.com/lite/ (POST) — endpoint primario.
redireccion `//duckduckgo.com/l/?uddg=<encoded>` que hay que decodificar. HTML minimo (ano 2009-style), tabla con `<a class='result-link'>` y
`<td class='result-snippet'>`. Es el menos agresivo con bot
detection; suele responder 200 cuando el endpoint `html.` ya
devuelve un challenge "anomaly" desde IPs residenciales/Windows.
2. https://html.duckduckgo.com/html/ (POST) — fallback. Su parser
usa `result__a` / `result__snippet`. DDG envuelve los enlaces en
`//duckduckgo.com/l/?uddg=<encoded>` que hay que decodificar.
Para automatizar busquedas masivas en el futuro (sesion persistente, Si ambos endpoints devuelven la pagina anti-bot ("anomaly", challenge
cookies, JS, captchas) la fase 2 introducira un enricher `web_search_cdp` captcha), el enricher emite un error claro indicando que se necesita
que controle un Chromium remoto via DevTools Protocol. Este es el `web_search_cdp` (issue 0029) — el fallback simple zero-infra no puede
fallback simple zero-infra. resolver el challenge.
""" """
from __future__ import annotations from __future__ import annotations
@@ -49,13 +55,33 @@ def now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
def fetch_ddg(query: str, timeout: int, region: str, safe: str) -> str: def _ddg_post(url: str, params: dict, headers: dict, timeout: int) -> str:
"""Descarga la pagina HTML de resultados de DuckDuckGo. try:
import requests # type: ignore
r = requests.post(url, data=params, headers=headers, timeout=timeout)
return r.text
except ImportError:
from urllib.parse import urlencode
from urllib.request import Request, urlopen
body = urlencode(params).encode()
req = Request(url, data=body, headers=headers)
with urlopen(req, timeout=timeout) as resp: # type: ignore
return resp.read().decode("utf-8", errors="replace")
El endpoint `html.duckduckgo.com` no requiere JS y respeta los
parametros `kl` (region) y `kp` (safe search: 1 strict, -1 off, def is_anomaly_page(htmltxt: str) -> bool:
-2 moderate). Inyecta cookie para que el "moderate" se aplique sin """Detecta la pagina anti-bot de DDG (challenge captcha)."""
pantalla intermedia. s = htmltxt.lower()
return "anomaly" in s and "challenge" in s
def fetch_ddg(query: str, timeout: int, region: str, safe: str) -> tuple[str, str]:
"""Descarga la pagina de resultados de DuckDuckGo.
Intenta primero `lite.duckduckgo.com/lite/` (HTML minimo, ano-2009
style, mucho menos agresivo con bot detection que `html.`). Si
ese endpoint devuelve la pagina anti-bot, cae al endpoint `html.`.
Devuelve `(html, source)` donde source ∈ {"lite", "html"}.
""" """
params = {"q": query} params = {"q": query}
if region: if region:
@@ -66,29 +92,22 @@ def fetch_ddg(query: str, timeout: int, region: str, safe: str) -> str:
headers = { headers = {
"User-Agent": ( "User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120 Safari/537.36" "(KHTML, like Gecko) Chrome/120 Safari/537.36"
), ),
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.7", "Accept-Language": "en-US,en;q=0.7",
} }
try:
import requests # type: ignore htmltxt = _ddg_post("https://lite.duckduckgo.com/lite/", params,
r = requests.post( headers, timeout)
"https://html.duckduckgo.com/html/", if not is_anomaly_page(htmltxt):
data=params, return htmltxt, "lite"
headers=headers,
timeout=timeout, log("lite endpoint devolvio challenge — fallback a html endpoint")
) htmltxt = _ddg_post("https://html.duckduckgo.com/html/", params,
return r.text headers, timeout)
except ImportError: return htmltxt, "html"
from urllib.parse import urlencode
from urllib.request import Request, urlopen
body = urlencode(params).encode()
req = Request("https://html.duckduckgo.com/html/", data=body,
headers=headers)
with urlopen(req, timeout=timeout) as resp: # type: ignore
return resp.read().decode("utf-8", errors="replace")
def decode_ddg_href(href: str) -> str: def decode_ddg_href(href: str) -> str:
@@ -195,7 +214,7 @@ class _DDGParser(HTMLParser):
def parse_ddg_html(htmltxt: str) -> list[dict]: def parse_ddg_html(htmltxt: str) -> list[dict]:
"""Parsea el HTML de DDG y devuelve [{url, title, snippet, rank}].""" """Parsea el HTML del endpoint `html.duckduckgo.com`."""
p = _DDGParser() p = _DDGParser()
try: try:
p.feed(htmltxt) p.feed(htmltxt)
@@ -221,6 +240,100 @@ def parse_ddg_html(htmltxt: str) -> list[dict]:
return out return out
class _DDGLiteParser(HTMLParser):
"""Parser para `lite.duckduckgo.com/lite/`.
Estructura tipica:
<a rel="nofollow" href="<URL>" class='result-link'>title</a>
...
<td class='result-snippet'>snippet text</td>
Los snippets vienen DESPUES del enlace (no hijo del mismo elemento),
asi que parea por orden: cada `result-link` consume el siguiente
`result-snippet`.
"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.results: list[dict] = []
self._in_link = False
self._in_snippet = False
self._cur_href = ""
self._title_buf: list[str] = []
self._snippet_buf: list[str] = []
self._pending_snippet_for: int | None = None
def _attrs_dict(self, attrs):
return {k: (v or "") for k, v in attrs}
def handle_starttag(self, tag: str, attrs):
a = self._attrs_dict(attrs)
cls = a.get("class", "")
if tag == "a" and "result-link" in cls:
href = a.get("href", "")
self._in_link = True
self._cur_href = href
self._title_buf = []
elif tag == "td" and "result-snippet" in cls:
self._in_snippet = True
self._snippet_buf = []
def handle_endtag(self, tag: str):
if self._in_link and tag == "a":
title = " ".join("".join(self._title_buf).split())
self.results.append({
"href": self._cur_href,
"title": title,
"snippet": "",
})
self._pending_snippet_for = len(self.results) - 1
self._in_link = False
elif self._in_snippet and tag == "td":
snippet = " ".join("".join(self._snippet_buf).split())
if self._pending_snippet_for is not None:
self.results[self._pending_snippet_for]["snippet"] = snippet
self._pending_snippet_for = None
self._in_snippet = False
def handle_data(self, data: str):
if self._in_link:
self._title_buf.append(data)
elif self._in_snippet:
self._snippet_buf.append(data)
def parse_ddg_lite(htmltxt: str) -> list[dict]:
"""Parsea el HTML del endpoint `lite.duckduckgo.com/lite/`."""
p = _DDGLiteParser()
try:
p.feed(htmltxt)
p.close()
except Exception as e:
log(f"DDG lite parser failed: {e}")
out: list[dict] = []
seen: set[str] = set()
for r in p.results:
href = r.get("href") or ""
# lite envia URLs absolutas directas; aun asi pasamos por
# decode_ddg_href por si en algun caso DDG envuelve.
url = decode_ddg_href(href)
if not url or not url.startswith(("http://", "https://")):
continue
# Excluir auto-promociones de DDG (paginas de ayuda).
if "duckduckgo.com/duckduckgo-help-pages/" in url:
continue
if url in seen:
continue
seen.add(url)
out.append({
"url": url,
"title": r.get("title") or "",
"snippet": r.get("snippet") or "",
"rank": len(out) + 1,
})
return out
def find_url_entity(conn: sqlite3.Connection, url: str) -> str | None: def find_url_entity(conn: sqlite3.Connection, url: str) -> str | None:
"""Busca un nodo Url existente con la misma url en metadata.""" """Busca un nodo Url existente con la misma url en metadata."""
cur = conn.execute( cur = conn.execute(
@@ -384,18 +497,40 @@ def main() -> int:
progress(0.10, "fetching") progress(0.10, "fetching")
try: try:
htmltxt = fetch_ddg(query, timeout=timeout_s, region=region, safe=safe) htmltxt, source = fetch_ddg(query, timeout=timeout_s,
region=region, safe=safe)
except Exception as e: except Exception as e:
log(f"DDG fetch failed: {e}") log(f"DDG fetch failed: {e}")
print(json.dumps({"error": str(e), "query": query, print(json.dumps({"error": str(e), "query": query,
"entities_added": 0, "relations_added": 0})) "entities_added": 0, "relations_added": 0}))
return 4 return 4
if is_anomaly_page(htmltxt):
log("DDG devolvio challenge captcha en ambos endpoints — "
"usar web_search_cdp (issue 0029) para resolver")
print(json.dumps({
"error": "DDG bot challenge — captcha required",
"query": query,
"engine": "duckduckgo",
"source": source,
"results": 0,
"entities_added": 0,
"relations_added": 0,
}, ensure_ascii=False))
return 4
progress(0.55, "parsing") progress(0.55, "parsing")
results = parse_ddg_html(htmltxt) # El parser se elige por contenido — si el endpoint y el markup no
# coinciden (tests con stub que sirve cualquier URL, o un cambio
# futuro de DDG), aun extraemos resultados. Probamos ambos y nos
# quedamos con el que devuelva mas.
results_lite = parse_ddg_lite(htmltxt) if "result-link" in htmltxt else []
results_html = parse_ddg_html(htmltxt) if "result__a" in htmltxt else []
results = results_lite if len(results_lite) >= len(results_html) else results_html
if limit > 0: if limit > 0:
results = results[:limit] results = results[:limit]
log(f"DDG returned {len(results)} results") log(f"DDG ({source}) returned {len(results)} results "
f"(lite_parsed={len(results_lite)} html_parsed={len(results_html)})")
progress(0.80, "applying") progress(0.80, "applying")
conn = sqlite3.connect(ops_db_path) conn = sqlite3.connect(ops_db_path)
+5 -12
View File
@@ -101,25 +101,18 @@ entities:
- { name: country, type: string } - { name: country, type: string }
- { name: postcode, type: string } - { name: postcode, type: string }
# Url — unifica el viejo Url (solo metadata) y Webpage (cuerpo
# cacheado). Tras fetch_webpage, los campos `*_path`, `status_code`,
# `fetched_at`, `text_length`, etc. tienen valor; sin haber corrido
# fetch siguen vacios pero el nodo sigue siendo un Url valido.
- name: Url - name: Url
color: "#89E0FC" color: "#89E0FC"
icon: ti-link icon: ti-link
principal_field: url principal_field: url
fields:
- { name: url, type: url, required: true }
- { name: title, type: string }
- { name: domain, type: string }
# Documento web descargado. Issue 0027: tipo separado de Url para nodos
# con cuerpo cacheado (HTML+markdown+screenshot). Los enrichers
# fetch_webpage / extract_links / extract_text_entities lo pueblan.
- name: Webpage
color: "#89E0FC"
icon: ti-file-text
principal_field: url
fields: fields:
- { name: url, type: url, required: true } - { name: url, type: url, required: true }
- { name: title, type: string } - { name: title, type: string }
- { name: domain, type: string }
- { name: status_code, type: int } - { name: status_code, type: int }
- { name: content_type, type: string } - { name: content_type, type: string }
- { name: fetched_at, type: date } - { name: fetched_at, type: date }
+31 -11
View File
@@ -378,15 +378,20 @@ std::string read_entity_field(const char* db_path, const char* id,
return out; return out;
} }
// JSON entregado al subprocess. Todos los paths se normalizan a WSL en // JSON entregado al subprocess. En Windows, los paths se normalizan a
// Windows; en POSIX los respeta tal cual. // forma WSL solo cuando el subprocess corre dentro de WSL (lang=bash, o
// python con runtime registry_venv). Para subprocesses nativos Windows
// (lang=go, o python embedded/FN_PYTHON/system) se mantienen los paths
// Windows-nativos — pasarlos como /mnt/c/... haria que fallen al abrir.
// En POSIX la conversion es no-op y siempre se respetan los paths.
std::string build_stdin_json(const std::string& job_id, std::string build_stdin_json(const std::string& job_id,
const std::string& enricher_id, const std::string& enricher_id,
const std::string& node_id, const std::string& node_id,
const std::string& params_json, const std::string& params_json,
const std::string& ops_db, const std::string& ops_db,
const std::string& app_dir, const std::string& app_dir,
const std::string& registry_root) const std::string& registry_root,
const std::string& lang)
{ {
std::string node_type, node_name, node_metadata = "{}"; std::string node_type, node_name, node_metadata = "{}";
if (!node_id.empty()) { if (!node_id.empty()) {
@@ -420,10 +425,25 @@ std::string build_stdin_json(const std::string& job_id,
std::string app_dir_abs = absify(app_dir); std::string app_dir_abs = absify(app_dir);
std::string root_abs = absify(registry_root); std::string root_abs = absify(registry_root);
std::string ops_db_wsl = to_wsl_path(ops_db_abs); // Decidir si convertir paths a forma WSL. Solo se hace cuando el
std::string app_dir_wsl = to_wsl_path(app_dir_abs); // subprocess vive dentro de WSL — si no, los paths /mnt/c/... no
std::string root_wsl = to_wsl_path(root_abs); // existen para el proceso Windows-nativo.
std::string cache_dir = app_dir_wsl + "/cache"; bool use_wsl_paths = false;
#ifdef _WIN32
if (lang == "bash") {
use_wsl_paths = true;
} else if (lang == "python") {
use_wsl_paths = cached_python_runtime().needs_wsl;
}
// lang == "go": siempre nativo Windows.
#else
(void)lang;
#endif
std::string ops_db_out = use_wsl_paths ? to_wsl_path(ops_db_abs) : ops_db_abs;
std::string app_dir_out = use_wsl_paths ? to_wsl_path(app_dir_abs) : app_dir_abs;
std::string root_out = use_wsl_paths ? to_wsl_path(root_abs) : root_abs;
std::string cache_dir = app_dir_out + "/cache";
std::ostringstream o; std::ostringstream o;
o << '{' o << '{'
@@ -434,10 +454,10 @@ std::string build_stdin_json(const std::string& job_id,
<< "\"node_name\":\"" << json_escape(node_name) << "\"," << "\"node_name\":\"" << json_escape(node_name) << "\","
<< "\"metadata\":" << (node_metadata.empty() ? "{}" : node_metadata) << "," << "\"metadata\":" << (node_metadata.empty() ? "{}" : node_metadata) << ","
<< "\"params\":" << (params_json.empty() ? "{}" : params_json) << "," << "\"params\":" << (params_json.empty() ? "{}" : params_json) << ","
<< "\"ops_db_path\":\"" << json_escape(ops_db_wsl) << "\"," << "\"ops_db_path\":\"" << json_escape(ops_db_out) << "\","
<< "\"app_dir\":\"" << json_escape(app_dir_wsl) << "\"," << "\"app_dir\":\"" << json_escape(app_dir_out) << "\","
<< "\"cache_dir\":\"" << json_escape(cache_dir) << "\"," << "\"cache_dir\":\"" << json_escape(cache_dir) << "\","
<< "\"registry_root\":\"" << json_escape(root_wsl) << "\"" << "\"registry_root\":\"" << json_escape(root_out) << "\""
<< '}'; << '}';
return o.str(); return o.str();
} }
@@ -1030,7 +1050,7 @@ void worker_loop() {
} }
std::string stdin_payload = build_stdin_json( std::string stdin_payload = build_stdin_json(
ctx.id, ctx.enricher_id, ctx.node_id, ctx.params_json, ctx.id, ctx.enricher_id, ctx.node_id, ctx.params_json,
ops_db, g_state->app_dir, g_state->registry_root); ops_db, g_state->app_dir, g_state->registry_root, lang);
ProcResult res = run_subprocess(job_id, run_path, lang, ProcResult res = run_subprocess(job_id, run_path, lang,
stdin_payload, ctrl); stdin_payload, ctrl);
+273 -20
View File
@@ -43,6 +43,8 @@
#include <cmath> #include <cmath>
#include <string> #include <string>
#include <sys/stat.h> #include <sys/stat.h>
#include <algorithm>
#include <unordered_map>
#include <vector> #include <vector>
#ifndef _WIN32 #ifndef _WIN32
@@ -318,27 +320,91 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
int park_n = 0; int park_n = 0;
int placed_neighbor = 0, placed_camera = 0, parked = 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); int parent = layout_first_placed_neighbor(g, i);
if (parent >= 0) { if (parent >= 0) orphans_by_anchor[parent].push_back(i);
float ox, oy; else orphans_no_anchor.push_back(i);
if (find_collision_free_slot( }
g, i, g.nodes[parent].x, g.nodes[parent].y,
min_dist, n.user_data, // ----- Pase 2: place clusters (orphans con anchor) -----
neighbor_radii, n_neighbor_radii, &ox, &oy)) { // Para cada anchor con sus hijos, los repartimos en un anillo
n.x = ox; n.y = oy; // alrededor del padre. Si hay mas hijos de los que caben en el
} else { // anillo base, abrimos anillos adicionales. Cada hijo sigue
// Acepta solape como ultimo recurso. // pasando find_collision_free_slot como fallback si el slot ideal
n.x = g.nodes[parent].x + neighbor_radii[n_neighbor_radii - 1]; // estaba ocupado por otro nodo del grafo.
n.y = g.nodes[parent].y; 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; ++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) { if (use_camera) {
// Sin vecino → colocar dentro de la camara con ring placement. // Sin vecino → colocar dentro de la camara con ring placement.
@@ -875,10 +941,29 @@ static void render_context_menu() {
} else { } else {
for (const auto& s : specs) { for (const auto& s : specs) {
if (ImGui::MenuItem(s.name.c_str())) { if (ImGui::MenuItem(s.name.c_str())) {
char job_id[64]; if (s.params.empty()) {
bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl, // Sin params editables: submit directo, comportamiento
"{}", job_id, sizeof(job_id)); // historico — un click y a correr.
if (ok) g_app.panel_jobs = true; 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()) { if (!s.description.empty() && ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", s.description.c_str()); ImGui::SetTooltip("%s", s.description.c_str());
@@ -891,6 +976,171 @@ static void render_context_menu() {
ImGui::EndPopup(); 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 // Label callback
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -1742,6 +1992,9 @@ static void render() {
ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
ge::chat_render(&g_app.panel_chat); ge::chat_render(&g_app.panel_chat);
// Enricher config window (abierto desde context menu Run enricher).
render_enricher_config_window();
g_first_render = false; g_first_render = false;
} }
+37
View File
@@ -0,0 +1,37 @@
"""Trampoline para invocar enrichers desde tests.
El Python embebido de Windows (`python-embed`) ignora `PYTHONPATH` por
diseno — el control de sys.path lo lleva el fichero `python312._pth`.
Para inyectar el stub `requests` de tests sin tocar ese fichero, los
tests llaman a este runner en vez de a `run.py` directamente:
python _runner.py <run.py>
El runner anade `$_STUB_PATHS` al frente de `sys.path` y ejecuta el
script objetivo como si hubiese sido invocado directamente.
"""
from __future__ import annotations
import os
import runpy
import sys
def main() -> int:
stub_paths = os.environ.get("_STUB_PATHS", "")
if stub_paths:
for p in stub_paths.split(os.pathsep):
if p and p not in sys.path:
sys.path.insert(0, p)
if len(sys.argv) < 2:
sys.stderr.write("usage: _runner.py <script>\n")
return 2
target = sys.argv[1]
sys.argv = [target] + sys.argv[2:]
runpy.run_path(target, run_name="__main__")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93 -5
View File
@@ -23,24 +23,106 @@ from pathlib import Path
import pytest import pytest
REGISTRY_ROOT = Path(__file__).resolve().parents[5]
APP_DIR_SRC = Path(__file__).resolve().parents[1] # graph_explorer/ APP_DIR_SRC = Path(__file__).resolve().parents[1] # graph_explorer/
ENRICHERS_DIR = APP_DIR_SRC / "enrichers"
TESTS_DIR = Path(__file__).resolve().parent TESTS_DIR = Path(__file__).resolve().parent
STUBS_DIR = TESTS_DIR / "_stubs" STUBS_DIR = TESTS_DIR / "_stubs"
PYTHON_BIN = REGISTRY_ROOT / "python" / ".venv" / "bin" / "python3"
# Los enrichers viven en `<app>/enrichers/` en el repo dev y en
# `<app>/assets/enrichers/` en la carpeta portable de Windows
# (convencion `assets/` desde el ADR de feb-2026). Detectar cual
# existe y usar ese.
def _resolve_enrichers_dir() -> Path:
cands = [
APP_DIR_SRC / "enrichers",
APP_DIR_SRC / "assets" / "enrichers",
]
for c in cands:
if c.is_dir():
return c
# Default a la primera para mensajes de error consistentes con el dev layout.
return cands[0]
ENRICHERS_DIR = _resolve_enrichers_dir()
def _resolve_registry_root() -> Path:
"""Sube desde el directorio de tests buscando un marker del registry.
En el repo: APP_DIR/projects/osint_graph/apps/graph_explorer/tests
-> 5 niveles arriba esta fn_registry/. En la carpeta de Windows
(Desktop/apps/graph_explorer/tests) NO hay registry — usamos el
propio app dir como fallback. Los tests no leen registry.db; solo
se pasa registry_root via ctx por compatibilidad con run.py.
"""
# Marker fiable: fichero `cmd/fn/main.go` o `registry.db`.
p = APP_DIR_SRC
for _ in range(8):
if (p / "cmd" / "fn" / "main.go").exists() or \
(p / "registry.db").exists():
return p
if p.parent == p:
break
p = p.parent
# Sin registry: usa el app dir como pseudo-root. Los tests funcionan
# igual mientras no haya un test que importe paquetes del registry.
return APP_DIR_SRC
REGISTRY_ROOT = _resolve_registry_root()
def _resolve_python_bin() -> Path:
"""Elige el Python con el que ejecutar los enrichers.
Prioridad (cubre Linux/WSL dev y Windows portable instalado):
1. $FN_TEST_PYTHON env override
2. <app>/assets/runtime/python/python.exe (Windows portable, solo Windows)
3. <app>/runtime/python/python.exe (legacy, solo Windows)
4. <registry>/python/.venv/bin/python3 (WSL dev venv)
5. sys.executable (whatever runs pytest)
Los candidatos `python.exe` solo se aceptan si corremos en Windows
nativo. En WSL/Linux pueden existir vendored en el repo (los
distribuibles), pero no son ejecutables en este OS.
"""
env = os.environ.get("FN_TEST_PYTHON")
if env and Path(env).exists():
return Path(env)
is_windows = sys.platform.startswith("win")
cands: list[Path] = []
if is_windows:
cands += [
APP_DIR_SRC / "assets" / "runtime" / "python" / "python.exe",
APP_DIR_SRC / "runtime" / "python" / "python.exe",
]
cands += [REGISTRY_ROOT / "python" / ".venv" / "bin" / "python3"]
for c in cands:
if c.exists():
return c
return Path(sys.executable)
PYTHON_BIN = _resolve_python_bin()
def stub_requests(tmp_path: Path, plan: dict) -> dict: def stub_requests(tmp_path: Path, plan: dict) -> dict:
"""Escribe el plan de respuestas y devuelve el env que activa el stub. """Escribe el plan de respuestas y devuelve el env que activa el stub.
El stub vive en tests/_stubs/requests.py y se activa via PYTHONPATH. Devuelve dos vias por las que `_runner.py` y un Python no-embedded
pueden inyectar el stub:
- `PYTHONPATH`: la ruta estandar; respeta el orden y el resto del
entorno. Funciona en Linux y en Python full instalado (no-embed).
- `_STUB_PATHS`: lo lee `_runner.py` y hace `sys.path.insert(0, ...)`.
Necesario en el Python embebido de Windows, que ignora
PYTHONPATH (lo controla `python312._pth`).
Plan acepta `default` y/o `match` (lista de {contains, status, text}). Plan acepta `default` y/o `match` (lista de {contains, status, text}).
""" """
plan_file = tmp_path / "_stub_plan.json" plan_file = tmp_path / "_stub_plan.json"
plan_file.write_text(json.dumps(plan), encoding="utf-8") plan_file.write_text(json.dumps(plan), encoding="utf-8")
return { return {
"PYTHONPATH": str(STUBS_DIR) + os.pathsep + os.environ.get("PYTHONPATH", ""), "PYTHONPATH": str(STUBS_DIR) + os.pathsep + os.environ.get("PYTHONPATH", ""),
"_STUB_PATHS": str(STUBS_DIR),
"_STUB_REQUESTS_PLAN": str(plan_file), "_STUB_REQUESTS_PLAN": str(plan_file),
} }
@@ -189,17 +271,23 @@ def run_enricher(enricher_id: str, ctx: dict, *, env: dict | None = None,
timeout: int = 30) -> tuple[int, dict | None, str]: timeout: int = 30) -> tuple[int, dict | None, str]:
"""Lanza enrichers/<id>/run.py con el wire protocol estandar. """Lanza enrichers/<id>/run.py con el wire protocol estandar.
Usa siempre el trampoline `_runner.py` para que el stub de
requests se inyecte tanto con PYTHONPATH (Python normal) como con
`_STUB_PATHS` (Python embebido de Windows que ignora PYTHONPATH).
Returns: (exit_code, stdout_json_or_None, stderr_text) Returns: (exit_code, stdout_json_or_None, stderr_text)
""" """
run_py = ENRICHERS_DIR / enricher_id / "run.py" run_py = ENRICHERS_DIR / enricher_id / "run.py"
assert run_py.exists(), f"no existe {run_py}" assert run_py.exists(), f"no existe {run_py}"
runner = TESTS_DIR / "_runner.py"
assert runner.exists(), f"no existe {runner}"
full_env = os.environ.copy() full_env = os.environ.copy()
if env: if env:
full_env.update(env) full_env.update(env)
proc = subprocess.run( proc = subprocess.run(
[str(PYTHON_BIN), str(run_py)], [str(PYTHON_BIN), str(runner), str(run_py)],
input=json.dumps(ctx), input=json.dumps(ctx),
capture_output=True, capture_output=True,
text=True, text=True,
+8 -1
View File
@@ -12,7 +12,9 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -130,12 +132,17 @@ def test_python_dummy_enricher_obeys_wire_protocol(tmp_path):
# Wire protocol — Bash (la ruta nueva) # Wire protocol — Bash (la ruta nueva)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.skipif(
sys.platform.startswith("win") or not shutil.which("bash"),
reason="test bash-only — saltado en Windows (el bash de WSL no acepta "
"rutas Windows nativas) y en sistemas sin bash",
)
def test_bash_dummy_enricher_obeys_wire_protocol(tmp_path): def test_bash_dummy_enricher_obeys_wire_protocol(tmp_path):
enr = _write_dummy_enricher(tmp_path, eid="dummy_sh", lang="bash") enr = _write_dummy_enricher(tmp_path, eid="dummy_sh", lang="bash")
ctx = json.dumps({"node_id": "n1", "ops_db_path": "", "params": {}}) ctx = json.dumps({"node_id": "n1", "ops_db_path": "", "params": {}})
proc = subprocess.run( proc = subprocess.run(
["/bin/bash", str(enr / "run.sh")], [shutil.which("bash"), str(enr / "run.sh")],
input=ctx, capture_output=True, text=True, timeout=10, input=ctx, capture_output=True, text=True, timeout=10,
) )
assert proc.returncode == 0, proc.stderr assert proc.returncode == 0, proc.stderr
+4 -4
View File
@@ -31,10 +31,10 @@ def test_extract_links_creates_url_nodes(ops_db, app_dir, registry_root):
# 2) Crear Webpage con metadata.markdown_path apuntando al cache. # 2) Crear Webpage con metadata.markdown_path apuntando al cache.
make_node(ops_db, node_id="w1", name="demo", make_node(ops_db, node_id="w1", name="demo",
type_ref="Webpage", metadata={"markdown_path": str(rel)}) type_ref="Url", metadata={"markdown_path": str(rel)})
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="w1", node_name="demo", node_type="Webpage", node_id="w1", node_name="demo", node_type="Url",
metadata={"markdown_path": str(rel)}) metadata={"markdown_path": str(rel)})
rc, out, err = run_enricher("extract_links", ctx) rc, out, err = run_enricher("extract_links", ctx)
@@ -54,9 +54,9 @@ def test_extract_links_creates_url_nodes(ops_db, app_dir, registry_root):
def test_extract_links_without_markdown_path_errors(ops_db, app_dir, def test_extract_links_without_markdown_path_errors(ops_db, app_dir,
registry_root): registry_root):
make_node(ops_db, node_id="w1", name="demo", make_node(ops_db, node_id="w1", name="demo",
type_ref="Webpage", metadata={}) type_ref="Url", metadata={})
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="w1", node_name="demo", node_type="Webpage") node_id="w1", node_name="demo", node_type="Url")
rc, out, err = run_enricher("extract_links", ctx) rc, out, err = run_enricher("extract_links", ctx)
assert rc != 0, "deberia fallar sin markdown_path" assert rc != 0, "deberia fallar sin markdown_path"
assert out is not None assert out is not None
+5 -5
View File
@@ -27,9 +27,9 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
rel = md_path.relative_to(app_dir) rel = md_path.relative_to(app_dir)
make_node(ops_db, node_id="w1", name="report", make_node(ops_db, node_id="w1", name="report",
type_ref="Webpage", metadata={"markdown_path": str(rel)}) type_ref="Url", metadata={"markdown_path": str(rel)})
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="w1", node_name="report", node_type="Webpage", node_id="w1", node_name="report", node_type="Url",
metadata={"markdown_path": str(rel)}) metadata={"markdown_path": str(rel)})
rc, out, err = run_enricher("extract_text_entities", ctx) rc, out, err = run_enricher("extract_text_entities", ctx)
@@ -38,7 +38,7 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
assert out["entities_added"] >= 3, out assert out["entities_added"] >= 3, out
types = {e["type_ref"] for e in list_entities(ops_db) types = {e["type_ref"] for e in list_entities(ops_db)
if e["type_ref"] != "Webpage"} if e["type_ref"] != "Url"}
# No exigimos todos los tipos — depende de que extract_iocs cubra cada # No exigimos todos los tipos — depende de que extract_iocs cubra cada
# patron — pero al menos Email y CVE deberian estar. # patron — pero al menos Email y CVE deberian estar.
assert "Email" in types, types assert "Email" in types, types
@@ -51,9 +51,9 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
def test_extract_iocs_without_markdown_errors(ops_db, app_dir, registry_root): def test_extract_iocs_without_markdown_errors(ops_db, app_dir, registry_root):
make_node(ops_db, node_id="w1", name="empty", make_node(ops_db, node_id="w1", name="empty",
type_ref="Webpage", metadata={}) type_ref="Url", metadata={})
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="w1", node_name="empty", node_type="Webpage") node_id="w1", node_name="empty", node_type="Url")
rc, out, err = run_enricher("extract_text_entities", ctx) rc, out, err = run_enricher("extract_text_entities", ctx)
assert rc != 0 assert rc != 0
assert out and "missing markdown_path" in (out.get("error") or "") assert out and "missing markdown_path" in (out.get("error") or "")
+2 -2
View File
@@ -42,9 +42,9 @@ def test_fetch_webpage_creates_domain_and_caches(ops_db, app_dir, registry_root,
assert out["entities_added"] == 1 # Domain assert out["entities_added"] == 1 # Domain
assert out["relations_added"] == 1 # BELONGS_TO assert out["relations_added"] == 1 # BELONGS_TO
# El nodo Url se promueve a Webpage. # El nodo Url permanece como Url (Webpage se unifico en Url).
e = get_entity(ops_db, "u1") e = get_entity(ops_db, "u1")
assert e["type_ref"] == "Webpage", e assert e["type_ref"] == "Url", e
assert e["metadata"]["title"] == "Acme Demo" assert e["metadata"]["title"] == "Acme Demo"
assert e["metadata"]["status_code"] == 200 assert e["metadata"]["status_code"] == 200
+6
View File
@@ -109,6 +109,12 @@ def test_resolver_uses_fn_python_env_var(tmp_path):
assert str(fake) in line, line assert str(fake) in line, line
@pytest.mark.skipif(
shutil.which("bash") is None or
not (APP_DIR_SRC / "tools" / "freeze_python_runtime.sh").exists(),
reason="bash o tools/freeze_python_runtime.sh no disponible "
"(esperado en deploy portable)",
)
def test_freeze_script_is_idempotent(tmp_path): def test_freeze_script_is_idempotent(tmp_path):
"""Llamadas consecutivas con mismas deps no rehacen el runtime.""" """Llamadas consecutivas con mismas deps no rehacen el runtime."""
fake_app = tmp_path / "app" fake_app = tmp_path / "app"
+11
View File
@@ -23,6 +23,17 @@ from conftest import APP_DIR_SRC, REGISTRY_ROOT
SCRIPT = APP_DIR_SRC / "tools" / "vendor_enricher_python.sh" SCRIPT = APP_DIR_SRC / "tools" / "vendor_enricher_python.sh"
# El script vendor es bash-only y vive en el repo dev. En la carpeta
# portable de Windows no esta presente; ademas necesitaria un bash
# real para ejecutarse. Saltamos toda la suite si:
# - no encontramos `bash` en PATH (Windows), o
# - el script no existe (deploy portable sin tools/).
pytestmark = pytest.mark.skipif(
shutil.which("bash") is None or not SCRIPT.exists(),
reason="bash o tools/vendor_enricher_python.sh no disponible "
"(esperado en deploy portable)",
)
def _make_enricher_dir(tmp_path: Path, manifest: str) -> Path: def _make_enricher_dir(tmp_path: Path, manifest: str) -> Path:
enr = tmp_path / "test_enricher" enr = tmp_path / "test_enricher"
+188 -127
View File
@@ -874,173 +874,234 @@ void views_inspector(AppState& app) {
bool any_change = false; bool any_change = false;
// ---- Identidad ---- // ---- Identidad ----
// Layout label-izquierda / input-derecha via 2-col table. El label
// alineado al frame del input y el input estirado al ancho restante.
ImGui::TextUnformatted("Identity"); ImGui::TextUnformatted("Identity");
ImGui::Separator(); ImGui::Separator();
if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf)))
any_change = true;
// type combo if (ImGui::BeginTable("##insp_id", 2,
{ ImGuiTableFlags_SizingStretchProp |
int cur = -1; ImGuiTableFlags_NoBordersInBody)) {
for (size_t i = 0; i < app.insp_type_options.size(); ++i) { ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; } ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
}
// Si el tipo no esta en el cache (raro), mostrar como tal y permitir // name
// introducirlo via input. Combo simple aqui. ImGui::TableNextRow(); ImGui::TableNextColumn();
if (ImGui::BeginCombo("type", app.insp_type_buf)) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("name");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::InputText("##name", app.insp_name_buf,
sizeof(app.insp_name_buf)))
any_change = true;
// type combo
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("type");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
{
int cur = -1;
for (size_t i = 0; i < app.insp_type_options.size(); ++i) { for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
bool is_sel = (int)i == cur; if (app.insp_type_options[i] == app.insp_type_buf) {
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) { cur = (int)i; break;
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
app.insp_type_options[i]);
any_change = true;
} }
if (is_sel) ImGui::SetItemDefaultFocus();
} }
ImGui::EndCombo(); if (ImGui::BeginCombo("##type", app.insp_type_buf)) {
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
bool is_sel = (int)i == cur;
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) {
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
app.insp_type_options[i]);
any_change = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
} }
// status combo
ImGui::TableNextRow(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("status");
ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::Combo("##status", &app.insp_status_idx,
k_status_options, k_status_count))
any_change = true;
ImGui::EndTable();
} }
// status combo
if (ImGui::Combo("status", &app.insp_status_idx, // description — multiline va debajo de su label, ocupando todo el
k_status_options, k_status_count)) // ancho. Con 60 px de alto entra ~3 lineas; el usuario hace scroll
any_change = true; // dentro del input para textos mas largos.
// description multiline ImGui::Spacing();
ImGui::TextUnformatted("description");
if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096); if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096);
if (ImGui::InputTextMultiline("description", if (ImGui::InputTextMultiline("##desc",
app.insp_desc_buf.data(), app.insp_desc_buf.data(),
app.insp_desc_buf.size(), app.insp_desc_buf.size(),
ImVec2(-FLT_MIN, 60.0f))) ImVec2(-FLT_MIN, 60.0f)))
any_change = true; any_change = true;
// ---- Schema fields + Extras ---- // ---- Schema fields + Extras ----
// Misma idea que Identity: 2-col table con label izquierda, input
// derecha. Para extras añadimos un boton trash inline; para URLs un
// boton Open. Ambos son SmallButton tras un input mas estrecho.
if (!app.insp_field_keys.empty()) { if (!app.insp_field_keys.empty()) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextUnformatted("Fields"); ImGui::TextUnformatted("Fields");
ImGui::Separator(); ImGui::Separator();
const EntitySpec* spec = find_entity_spec(app.parsed_types, const EntitySpec* spec = find_entity_spec(app.parsed_types,
app.insp_type_buf); app.insp_type_buf);
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
const std::string& key = app.insp_field_keys[i];
std::string& val = app.insp_field_values[i];
bool is_extra = app.insp_is_extra[i] != 0;
ImGui::PushID((int)i);
// Encuentra la FieldSpec si es del schema. if (ImGui::BeginTable("##insp_fields", 2,
const FieldSpec* fs = nullptr; ImGuiTableFlags_SizingStretchProp |
if (!is_extra && spec) { ImGuiTableFlags_NoBordersInBody)) {
for (const auto& f : spec->fields) { ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
if (f.name == key) { fs = &f; break; } ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
}
}
FieldKind kind = fs ? fs->kind : FK_STRING; for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
std::string label = key; const std::string& key = app.insp_field_keys[i];
if (fs && fs->required) label += " *"; std::string& val = app.insp_field_values[i];
if (is_extra) label = "[extra] " + key; bool is_extra = app.insp_is_extra[i] != 0;
ImGui::PushID((int)i);
char buf[1024]; // Encuentra la FieldSpec si es del schema.
size_t k = std::min(sizeof(buf) - 1, val.size()); const FieldSpec* fs = nullptr;
std::memcpy(buf, val.data(), k); if (!is_extra && spec) {
buf[k] = 0; for (const auto& f : spec->fields) {
if (f.name == key) { fs = &f; break; }
bool changed = false;
switch (kind) {
case FK_BOOL: {
bool b = (val == "true" || val == "1");
if (ImGui::Checkbox(label.c_str(), &b)) {
val = b ? "true" : "false";
changed = true;
} }
break;
} }
case FK_INT: {
int n = std::atoi(val.c_str()); FieldKind kind = fs ? fs->kind : FK_STRING;
if (ImGui::InputInt(label.c_str(), &n)) {
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n); // Label izquierdo. Marca `*` si es required, prefijo
val = nb; // [extra] si es campo libre añadido por el usuario.
changed = true; ImGui::TableNextRow(); ImGui::TableNextColumn();
} ImGui::AlignTextToFramePadding();
break; if (is_extra) {
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.65f, 0.65f, 0.50f, 1.0f));
ImGui::Text("%s", key.c_str());
ImGui::PopStyleColor();
} else if (fs && fs->required) {
ImGui::Text("%s *", key.c_str());
} else {
ImGui::TextUnformatted(key.c_str());
} }
case FK_FLOAT: {
double d = std::atof(val.c_str()); // Input derecha. Reserva espacio para el trailing button
if (ImGui::InputDouble(label.c_str(), &d, 0.0, 0.0, "%.6g")) { // cuando aplique (URL Open, extras trash).
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d); ImGui::TableNextColumn();
val = nb; bool needs_trail_btn = is_extra ||
changed = true; (kind == FK_URL && !val.empty() &&
} (val.rfind("http://", 0) == 0 ||
break; val.rfind("https://", 0) == 0));
} ImGui::SetNextItemWidth(needs_trail_btn ? -32.0f : -FLT_MIN);
case FK_ENUM: {
if (fs && !fs->enum_values.empty()) { char buf[1024];
int cur = -1; size_t k = std::min(sizeof(buf) - 1, val.size());
for (size_t e = 0; e < fs->enum_values.size(); ++e) { std::memcpy(buf, val.data(), k);
if (fs->enum_values[e] == val) { cur = (int)e; break; } buf[k] = 0;
bool changed = false;
switch (kind) {
case FK_BOOL: {
bool b = (val == "true" || val == "1");
if (ImGui::Checkbox("##v", &b)) {
val = b ? "true" : "false";
changed = true;
} }
if (ImGui::BeginCombo(label.c_str(), val.c_str())) { break;
}
case FK_INT: {
int n = std::atoi(val.c_str());
if (ImGui::InputInt("##v", &n)) {
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n);
val = nb;
changed = true;
}
break;
}
case FK_FLOAT: {
double d = std::atof(val.c_str());
if (ImGui::InputDouble("##v", &d, 0.0, 0.0, "%.6g")) {
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d);
val = nb;
changed = true;
}
break;
}
case FK_ENUM: {
if (fs && !fs->enum_values.empty()) {
int cur = -1;
for (size_t e = 0; e < fs->enum_values.size(); ++e) { for (size_t e = 0; e < fs->enum_values.size(); ++e) {
bool is_sel = (int)e == cur; if (fs->enum_values[e] == val) { cur = (int)e; break; }
if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) { }
val = fs->enum_values[e]; if (ImGui::BeginCombo("##v", val.c_str())) {
changed = true; for (size_t e = 0; e < fs->enum_values.size(); ++e) {
} bool is_sel = (int)e == cur;
if (is_sel) ImGui::SetItemDefaultFocus(); if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) {
val = fs->enum_values[e];
changed = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
} else {
if (ImGui::InputText("##v", buf, sizeof(buf))) {
val = buf;
changed = true;
} }
ImGui::EndCombo();
} }
} else { break;
// Sin valores: tratar como string }
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) { case FK_URL:
if (ImGui::InputText("##v", buf, sizeof(buf))) {
val = buf; val = buf;
changed = true; changed = true;
} }
} if (!val.empty() &&
break; (val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
} ImGui::SameLine();
case FK_URL: if (ImGui::SmallButton(TI_EXTERNAL_LINK "##url")) {
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
val = buf;
changed = true;
}
if (!val.empty() &&
(val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
ImGui::SameLine();
if (ImGui::SmallButton("Open##url")) {
#if defined(_WIN32) #if defined(_WIN32)
std::string cmd = "start \"\" \"" + val + "\""; std::string cmd = "start \"\" \"" + val + "\"";
#else #else
std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &"; std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &";
#endif #endif
int rc = std::system(cmd.c_str()); (void)rc; int rc = std::system(cmd.c_str()); (void)rc;
}
} }
} break;
break; case FK_DATE:
case FK_DATE: case FK_STRING:
case FK_STRING: default:
default: if (ImGui::InputTextWithHint("##v",
if (ImGui::InputTextWithHint(label.c_str(), kind == FK_DATE ? "YYYY-MM-DD" : "",
kind == FK_DATE ? "YYYY-MM-DD" : "", buf, sizeof(buf))) {
buf, sizeof(buf))) { val = buf;
val = buf; changed = true;
changed = true; }
} break;
break;
}
if (is_extra) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_TRASH "##rm")) {
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
app.insp_field_values.erase(app.insp_field_values.begin() + i);
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
ImGui::PopID();
any_change = true;
--i;
continue;
} }
if (is_extra) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_TRASH "##rm")) {
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
app.insp_field_values.erase(app.insp_field_values.begin() + i);
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
ImGui::PopID();
any_change = true;
--i;
continue;
}
}
if (changed) any_change = true;
ImGui::PopID();
} }
if (changed) any_change = true; ImGui::EndTable();
ImGui::PopID();
} }
} }
+16
View File
@@ -246,6 +246,22 @@ struct AppState {
bool filter_dirty = false; // pide reapply bool filter_dirty = false; // pide reapply
int filter_focus_target = -1; // node_idx a centrar int filter_focus_target = -1; // node_idx a centrar
char filter_tag_input[64] = {}; // input de chip nuevo char filter_tag_input[64] = {}; // input de chip nuevo
// ---- Enricher config window --------------------------------------------
// Cuando el usuario clica un enricher con `params` no vacios en el
// context menu, se rellena este bloque y se abre una ventana ImGui
// (no modal) que permite ajustar los valores antes de submitear el
// job. La ventana es dockeable y movible; cerrar la X cancela.
// Si el enricher no declara params, se submitea directamente con `{}`
// sin pasar por aqui.
bool enr_window_open = false; // visibilidad
std::string enr_modal_id; // enricher.id
std::string enr_modal_node_id; // sql_id del nodo
std::string enr_modal_node_label; // label visible
// Buffer editable por param. Tamano fijo 256 para inputs de texto;
// suficiente para queries y URLs cortas. Indices alineados con
// EnricherSpec::params del enricher seleccionado.
std::vector<std::vector<char>> enr_modal_param_bufs;
}; };
// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).