feat(0013): add extract_panel — UI + subprocess + apply (dedupe)

extract_panel.{h,cpp}: panel ImGui dockeable con textarea grande,
boton Extract que lanza enrichers/paste_extract/run.py en un
std::thread aparte (no bloquea UI), tablas editables de entidades y
relaciones propuestas con checkboxes, y boton Apply Selected que
persiste a operations.db con dedupe por (type_ref, name) y por
(from, to, name) en relaciones.

Parser JSON ad-hoc (suficiente para el contrato del enricher) para
no añadir dependencias. Apply usa SQLite directamente (mismo
patron que entity_ops/jobs.cpp).

Anade panel_extract a AppState. La logica apply esta separada de
ImGui para poder testarla en aislamiento desde pytest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 14:24:26 +02:00
parent 009d387d9a
commit fdc6b91f4d
3 changed files with 1219 additions and 0 deletions
+1079
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
#pragma once
#include <atomic>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// Panel "Paste & Extract" (issue 0013).
//
// Textarea grande para pegar texto. Boton Extract lanza el script
// `enrichers/paste_extract/run.py` en un hilo aparte (no bloquea UI).
// El script devuelve un JSON con entidades y relaciones propuestas (modo
// preview — no escribe a operations.db). El panel muestra dos tablas
// (entidades / relaciones) con checkboxes; al pulsar "Apply Selected"
// se persisten via entity_ops con dedupe por (type_ref, name).
//
// Threading: una llamada Extract a la vez (extract_busy bool). El hilo
// rellena la propuesta tras hacerse el subprocess. Apply corre en el
// thread principal y dispara reload del grafo via app.want_reload.
//
// El enricher esta declarado en `enrichers/paste_extract/manifest.yaml`
// pero NO se invoca via el sistema de jobs — el panel lo lanza
// directamente. Vivir en `enrichers/` permite que se distribuya y que
// el script use el mismo Python runtime resolution que el resto.
namespace ge {
struct AppState;
// Una entidad propuesta por el extractor. Se guarda como string para
// poder editarla inline antes del Apply.
struct ProposedEntity {
std::string tmp_id; // "tmp_0", "tmp_1", ... vinculado a relaciones
std::string type_ref; // editable
std::string name; // editable
std::string source; // "regex" | "hybrid"
int start_offset = -1; // span en el texto pegado
int end_offset = -1;
double confidence = 1.0;
std::string metadata_json; // JSON literal (no editable v1)
bool selected = true;
// Buffers mutables para edicion inline en ImGui.
char type_buf[64] = {};
char name_buf[256] = {};
};
struct ProposedRelation {
std::string from_tmp_id;
std::string to_tmp_id;
std::string name; // ej: "works_at"
std::string source; // "hybrid" | ...
double confidence = 0.0;
bool selected = true;
};
struct ExtractResult {
std::vector<ProposedEntity> entities;
std::vector<ProposedRelation> relations;
std::vector<std::string> layers;
std::string error; // vacio si OK
std::string stderr_tail;
};
struct ExtractPanelState {
// Buffer de texto del textarea. Crece dinamicamente.
std::vector<char> text_buf;
bool text_initialized = false;
// Resultado del ultimo Extract (poblado por el worker thread).
std::shared_ptr<ExtractResult> result;
std::mutex result_mu;
std::atomic<bool> busy{false};
std::atomic<bool> new_result{false}; // hay resultado fresco
// Mensaje de status (en el footer) — refrescado por el worker.
std::string status;
// Stats del ultimo apply.
int last_apply_entities = 0;
int last_apply_relations = 0;
int last_apply_dedup = 0;
// Toggle: ¿usar hybrid (GLiNER/GLiREL) si esta disponible?
bool use_hybrid = false;
// Worker thread; joinable cuando esta vivo.
std::thread worker;
};
// Configura paths que el worker necesita para invocar Python. Llamar una
// vez tras `jobs_init` (re-usa el resolver de Python runtime + paths).
void extract_panel_init(const char* enrichers_dir,
const char* app_dir,
const char* registry_root);
// Suelta el worker thread si esta corriendo (cancelable). Llamar al
// shutdown de la app.
void extract_panel_shutdown();
// Renderiza el panel. Si app.panel_extract es false, retorna sin dibujar.
void extract_panel_render(AppState& app);
// Aplica las entidades/relaciones marcadas como selected al
// operations.db indicado. Inserta entidades nuevas con dedupe por
// (type_ref, name); reusa el id existente si lo encuentra. Despues
// inserta las relaciones cuyos endpoints (mapeados via tmp_id ->
// real_id) sean ambos validos.
//
// Devuelve los conteos en out_added_entities, out_dedup_entities,
// out_added_relations. Tolera que algunas relaciones no resuelvan
// (out_skipped_relations). El caller decide si setear app.want_reload.
//
// Esta funcion es testeable en aislamiento (no toca ImGui).
bool extract_panel_apply(const char* ops_db_path,
const ExtractResult& result,
int* out_added_entities,
int* out_dedup_entities,
int* out_added_relations,
int* out_skipped_relations);
// Helper interno expuesto para tests: parsea el JSON que produce
// `enrichers/paste_extract/run.py`. Devuelve true si el parseo es OK.
// En error, result.error se rellena.
bool extract_panel_parse_result(const std::string& json_text,
ExtractResult* result);
// Spawnea el subprocess Python para extraer. Sincronico (bloquea el
// hilo del caller). El panel lo invoca en un std::thread aparte para
// no congelar la UI. Expuesto por si los tests quieren llamarlo
// directamente (no por ahora — los tests cubren el lado Python via
// pytest, y el lado C++ via parse_result + apply).
bool extract_panel_run_subprocess(const std::string& text,
bool use_hybrid,
ExtractResult* out);
} // namespace ge
+1
View File
@@ -62,6 +62,7 @@ struct AppState {
bool panel_note = false;
bool panel_jobs = false; // issue 0026
bool panel_chat = false; // claude -p chat (issue 0001)
bool panel_extract = false; // paste & extract (issue 0013)
bool show_filters_modal = false;
bool show_open_modal = false;