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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ jobs:
pip install -r requirements.txt
pip install -e .

- name: Ensure that the data folder exists
run: mkdir -p data

- name: Download the relase database for test
run: gh release download dados-teste -p "olist_relational.db" -D data/
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Run deterministic test layers
run: |
pytest \
Expand Down
10 changes: 9 additions & 1 deletion ARQUITETURA.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ Arquivo: `text_to_insight/state.py`

Campos obrigatorios:

- `pergunta_usuario`
- `pergunta_original`
- `pergunta_atual`
- `db_path`

Campos principais do fluxo:
Expand All @@ -147,6 +148,13 @@ Campos principais do fluxo:
- `status`, `tentativas_loop`, `historico_conversa`, `espera_humana`, `pergunta_ao_usuario`
- telemetria: `tokens_input`, `tokens_output`, `tokens_total`

## HITL e perguntas

- `pergunta_original` e a pergunta inicial da thread (imutavel apos o primeiro set).
- `pergunta_atual` e a pergunta corrente e fonte de verdade para todo o fluxo.
- Em HITL, se a resposta do usuario for classificada como "nova pergunta",
o sistema atualiza `pergunta_atual` e reinicia o ciclo (sem alterar a original).

## Status operacionais

No runtime/engine podem aparecer:
Expand Down
7 changes: 7 additions & 0 deletions DESENVOLVIMENTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ Criterios minimos:
- retomada por `thread_id`;
- gravacao de metricas em CSV.

## Contrato HITL (perguntas)

- `pergunta_original`: pergunta inicial da thread, imutavel apos o primeiro set.
- `pergunta_atual`: pergunta corrente e fonte de verdade para o fluxo.
- Em HITL, se a resposta do usuario for classificada como "nova pergunta",
o sistema atualiza `pergunta_atual` e reinicia o ciclo, sem alterar a original.

## Execucao

```bash
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ O runtime padrao garante:
- retomada por `thread_id`;
- persistencia de metricas em `data/metricas_execucao.csv`.

## Contrato HITL (perguntas)

- `pergunta_original`: pergunta inicial da thread, imutavel apos o primeiro set.
- `pergunta_atual`: pergunta corrente e fonte de verdade para o fluxo.
- Em HITL, se a resposta do usuario for classificada como "nova pergunta",
o sistema atualiza `pergunta_atual` e reinicia o ciclo, sem alterar a original.

## Instalacao

```bash
Expand Down
430 changes: 430 additions & 0 deletions tests/cassettes/test_integracao/test_hitl_nova_pergunta_substitui.yaml

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion tests/test_biblioteca_integracao.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ def stream(self, estado_execucao, config, stream_mode="values"):
state = self._thread_state(thread_id)

if estado_execucao is not None:
pergunta = str(estado_execucao.get("pergunta_usuario", ""))
pergunta = (
str(estado_execucao.get("pergunta_atual", ""))
or str(estado_execucao.get("pergunta_original", ""))
or str(estado_execucao.get("pergunta_usuario", ""))
)
if "hitl" in pergunta.lower():
state["values"] = {
**estado_execucao,
Expand Down
10 changes: 5 additions & 5 deletions tests/test_componentes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_schema_extrai_tabelas():
"""Schema node retorna contexto com tabelas do olist DB."""
from text_to_insight.nodes.schema import nos_nodo_esquema

estado = {"db_path": DB_PATH, "pergunta_usuario": "teste"}
estado = {"db_path": DB_PATH, "pergunta_atual": "teste"}
resultado = nos_nodo_esquema(estado)

assert resultado["status"] == "schema_obtido"
Expand All @@ -34,7 +34,7 @@ def test_schema_erro_db_invalido():
"""Schema node retorna erro quando db_path não existe."""
from text_to_insight.nodes.schema import nos_nodo_esquema

estado = {"db_path": "/caminho/inexistente.db", "pergunta_usuario": "teste"}
estado = {"db_path": "/caminho/inexistente.db", "pergunta_atual": "teste"}
resultado = nos_nodo_esquema(estado)

assert resultado["status"] == "exec_erro"
Expand Down Expand Up @@ -145,7 +145,7 @@ def test_executor_sucesso():
estado = {
"sql_gerada": "SELECT COUNT(*) as total FROM orders",
"db_path": DB_PATH,
"pergunta_usuario": "teste",
"pergunta_atual": "teste",
}
resultado = nos_nodo_sandbox(estado)

Expand All @@ -161,7 +161,7 @@ def test_executor_sql_vazia():
estado = {
"sql_gerada": "",
"db_path": DB_PATH,
"pergunta_usuario": "teste",
"pergunta_atual": "teste",
}
resultado = nos_nodo_sandbox(estado)

Expand All @@ -175,7 +175,7 @@ def test_executor_sql_com_erro():
estado = {
"sql_gerada": "SELECT * FROM tabela_que_nao_existe",
"db_path": DB_PATH,
"pergunta_usuario": "teste",
"pergunta_atual": "teste",
}
resultado = nos_nodo_sandbox(estado)

Expand Down
33 changes: 31 additions & 2 deletions tests/test_integracao.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ def rate_limit_delay():

def _estado_inicial(pergunta: str) -> dict:
return {
"pergunta_usuario": pergunta,
"pergunta_original": pergunta,
"pergunta_atual": pergunta,
"historico_conversa": [],
"contexto_schema": "",
"sql_gerada": "",
Expand Down Expand Up @@ -97,4 +98,32 @@ def test_estado_final_completo(grafo):
assert resultado.get("contexto_schema", "") != ""
assert resultado.get("sql_gerada", "") != ""
assert resultado.get("saida_terminal", "") != ""
assert resultado.get("tentativas_loop", 0) >= 1
assert resultado.get("tentativas_loop", 0) >= 1


@pytest.mark.vcr
@pytest.mark.timeout(180)
def test_hitl_nova_pergunta_substitui(grafo):
"""HITL com nova pergunta deve substituir pergunta_atual sem mudar a original."""
from text_to_insight.runtime import registrar_resposta_humana

config = {"configurable": {"thread_id": "teste_hitl_nova_pergunta"}}
grafo.grafo_text_to_insight.invoke(_estado_inicial("Quem e o Brad Pitt?"), config)

snapshot = grafo.grafo_text_to_insight.get_state(config)
assert snapshot.values.get("espera_humana") is True or snapshot.values.get("status") == "aguardando_input"

registrar_resposta_humana(
grafo_app=grafo.grafo_text_to_insight,
config=config,
user_response="Quero saber quantos clientes existem",
)

for _ in grafo.grafo_text_to_insight.stream(None, config, stream_mode="values"):
pass

resultado_final = grafo.grafo_text_to_insight.get_state(config).values

assert resultado_final.get("pergunta_original") == "Quem e o Brad Pitt?"
assert resultado_final.get("pergunta_atual") == "Quero saber quantos clientes existem"
assert "Brad Pitt" not in str(resultado_final.get("resposta_natural", ""))
6 changes: 5 additions & 1 deletion tests/test_main_engine_integracao.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ def stream(self, estado_execucao, config, stream_mode="values"):
state = self._thread_state(thread_id)

if estado_execucao is not None:
pergunta = str(estado_execucao.get("pergunta_usuario", ""))
pergunta = (
str(estado_execucao.get("pergunta_atual", ""))
or str(estado_execucao.get("pergunta_original", ""))
or str(estado_execucao.get("pergunta_usuario", ""))
)
if "hitl" in pergunta.lower():
state["values"] = {
**estado_execucao,
Expand Down
24 changes: 12 additions & 12 deletions tests/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def llm():
def _obter_schema_real() -> str:
"""Helper: extrai schema real do olist DB (sem API, só SQLite)."""
from text_to_insight.nodes.schema import nos_nodo_esquema
resultado = nos_nodo_esquema({"db_path": DB_PATH, "pergunta_usuario": "teste"})
resultado = nos_nodo_esquema({"db_path": DB_PATH, "pergunta_atual": "teste"})
return resultado["contexto_schema"]


Expand All @@ -50,7 +50,7 @@ def test_planner_sem_schema(llm):
from text_to_insight.nodes.planner import nos_nodo_planejador

estado = {
"pergunta_usuario": "Quantos pedidos existem?",
"pergunta_atual": "Quantos pedidos existem?",
"contexto_schema": "",
"feedback_critico": "",
"status": "iniciado",
Expand All @@ -73,7 +73,7 @@ def test_planner_com_schema_decide_codificar(llm):

schema = _obter_schema_real()
estado = {
"pergunta_usuario": "Quantos pedidos existem no banco?",
"pergunta_atual": "Quantos pedidos existem no banco?",
"contexto_schema": schema,
"feedback_critico": "",
"status": "schema_obtido",
Expand All @@ -95,7 +95,7 @@ def test_planner_com_feedback_revisa(llm):
time.sleep(5) # rate limit
schema = _obter_schema_real()
estado = {
"pergunta_usuario": "Quantos pedidos existem no banco?",
"pergunta_atual": "Quantos pedidos existem no banco?",
"contexto_schema": schema,
"feedback_critico": "A SQL retornou dados incorretos, faltou filtrar por status.",
"status": "reprovado",
Expand All @@ -116,7 +116,7 @@ def test_planner_pergunta_fora_de_escopo(llm):
time.sleep(5) # rate limit
schema = _obter_schema_real()
estado = {
"pergunta_usuario": "Quantas vezes a Ahri ganhou o CBLOL?",
"pergunta_atual": "Quantas vezes a Ahri ganhou o CBLOL?",
"contexto_schema": schema,
"feedback_critico": "",
"status": "schema_obtido",
Expand Down Expand Up @@ -145,7 +145,7 @@ def test_code_agent_gera_sql(llm):
time.sleep(5) # rate limit
schema = _obter_schema_real()
estado = {
"pergunta_usuario": "Quantos pedidos existem no banco?",
"pergunta_atual": "Quantos pedidos existem no banco?",
"contexto_schema": schema,
"feedback_critico": "",
"sql_gerada": "",
Expand Down Expand Up @@ -173,7 +173,7 @@ def test_code_agent_com_feedback_regenera(llm):
time.sleep(5)
schema = _obter_schema_real()
estado = {
"pergunta_usuario": "Quais as 5 categorias de produtos mais vendidas?",
"pergunta_atual": "Quais as 5 categorias de produtos mais vendidas?",
"contexto_schema": schema,
"feedback_critico": "A SQL anterior não tinha LIMIT 5, corrija.",
"sql_gerada": "SELECT product_category_name FROM products",
Expand All @@ -197,7 +197,7 @@ def test_executor_com_sql_real():
estado = {
"sql_gerada": "SELECT COUNT(*) as total_pedidos FROM orders",
"db_path": DB_PATH,
"pergunta_usuario": "Quantos pedidos existem?",
"pergunta_atual": "Quantos pedidos existem?",
}
resultado = nos_nodo_sandbox(estado)

Expand All @@ -219,7 +219,7 @@ def test_critic_avalia_resultado_correto(llm):

time.sleep(5) # rate limit
estado = {
"pergunta_usuario": "Quantos pedidos existem no banco?",
"pergunta_atual": "Quantos pedidos existem no banco?",
"sql_gerada": "SELECT COUNT(*) as total_pedidos FROM orders",
"linhas_resultado_preview": [{"total_pedidos": 99441}],
"total_linhas_resultado": 1,
Expand All @@ -240,7 +240,7 @@ def test_critic_reprova_erro_execucao(llm):
from text_to_insight.nodes.critic import nos_nodo_critico

estado = {
"pergunta_usuario": "Quantos pedidos existem?",
"pergunta_atual": "Quantos pedidos existem?",
"sql_gerada": "SELECT * FROM tabela_inexistente",
"linhas_resultado_preview": [],
"total_linhas_resultado": 0,
Expand Down Expand Up @@ -271,7 +271,7 @@ def test_cadeia_code_agent_executor(llm):

# Passo 1: Code Agent gera SQL
estado_code = {
"pergunta_usuario": "Quantos clientes existem no banco?",
"pergunta_atual": "Quantos clientes existem no banco?",
"contexto_schema": schema,
"feedback_critico": "",
"sql_gerada": "",
Expand All @@ -286,7 +286,7 @@ def test_cadeia_code_agent_executor(llm):
estado_exec = {
"sql_gerada": resultado_code["sql_gerada"],
"db_path": DB_PATH,
"pergunta_usuario": "Quantos clientes existem no banco?",
"pergunta_atual": "Quantos clientes existem no banco?",
}
resultado_exec = nos_nodo_sandbox(estado_exec)
print(f" → Status execução: {resultado_exec['status']}")
Expand Down
7 changes: 6 additions & 1 deletion text_to_insight/InsightEngine.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ def get_insight(
if snapshot.next and user_response:
registrar_resposta_humana(app, config, user_response)
estado_execucao = None
pergunta_exibicao = snapshot.values.get("pergunta_usuario", "Retomando conversa...")
pergunta_exibicao = (
snapshot.values.get("pergunta_atual")
or snapshot.values.get("pergunta_original")
or snapshot.values.get("pergunta_usuario")
or "Retomando conversa..."
)
# Caso 2: chamada nova (primeira execução para essa pergunta).
elif query:
estado_execucao = construir_estado_inicial(query, self._db_path)
Expand Down
2 changes: 1 addition & 1 deletion text_to_insight/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def main(argv: list[str] | None = None) -> dict[str, Any]:
callback = _coletar_resposta_humana if hitl_ativado else None
resultado = engine.run(thread_id=args.thread_id, query=pergunta, on_human_prompt=callback)

# Fallback para clientes que prefiram retomar manualmente sem callback.
# Fallback (plano B) para clientes que prefiram retomar manualmente sem callback.
while resultado.get("status") == "AWAITING_USER":
resposta = _coletar_resposta_humana(resultado.get("message", "Pode confirmar o prosseguimento?"))
resultado = engine.resume(
Expand Down
1 change: 1 addition & 0 deletions text_to_insight/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def _compilar_grafo(self, hitl: bool) -> "CompiledStateGraph":
construtor = self._construir_grafo_text_to_insight(hitl)
grafo_compilado = construtor.compile(checkpointer=self.memory,
interrupt_before=["espera_humana"])
grafo_compilado.hitl_classifier_llm = self.llm
print("[GRAFO] Grafo Text-to-Insight compilado com sucesso!")
return grafo_compilado

Expand Down
6 changes: 5 additions & 1 deletion text_to_insight/nodes/code_agent/code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ def nos_nodo_agente_codigo(estado: EstadoTextToInsight, llm: ChatGoogleGenerativ
"""
Nó Agente de Código: usa Gemini para gerar SQL a partir da pergunta + schema.
"""
pergunta = estado.get("pergunta_usuario", "")
pergunta = (
estado.get("pergunta_atual", "")
or estado.get("pergunta_original", "")
or estado.get("pergunta_usuario", "")
)
conversa_previa = estado.get("historico_conversa", "")
schema = estado.get("contexto_schema", "")
historico = estado.get("historico_tentativas", [])
Expand Down
6 changes: 5 additions & 1 deletion text_to_insight/nodes/critic.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ def nos_nodo_critico(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) -
"""
Nó Crítico: usa Gemini para avaliar qualidade do resultado.
"""
pergunta = estado.get("pergunta_usuario", "")
pergunta = (
estado.get("pergunta_atual", "")
or estado.get("pergunta_original", "")
or estado.get("pergunta_usuario", "")
)
sql = estado.get("sql_gerada", "")
preview = estado.get("linhas_resultado_preview", [])
total = estado.get("total_linhas_resultado", 0)
Expand Down
10 changes: 7 additions & 3 deletions text_to_insight/nodes/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI

Lógica determinística para schema vazio; LLM para decisões mais complexas.
"""
pergunta = estado.get("pergunta_usuario", "")
pergunta = (
estado.get("pergunta_atual", "")
or estado.get("pergunta_original", "")
or estado.get("pergunta_usuario", "")
)
conversa_previa = estado.get("historico_conversa", "")
schema = estado.get("contexto_schema", "")
feedback = estado.get("feedback_critico", "")
Expand Down Expand Up @@ -109,8 +113,8 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI
tentativas=tentativas,
status_atual=status,
erro=erro if erro else "Nenhum",
# apenas primeiros 500 caracteres do schema para evitar estourar o prompt, mas pode ser ajustado conforme necessidade
schema=schema[:500] if schema else "Nenhum",
# passamos schema inteiro agora
schema=schema if schema else "Nenhum",
conversa_previa=conversa_previa if conversa_previa else "Nenhuma",
diretrizes=diretrizes,
)
Expand Down
8 changes: 6 additions & 2 deletions text_to_insight/nodes/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
usuário final.

Instruções:
- Use a pergunta original e a SQL executada como contexto.
- Use a pergunta atual e a SQL executada como contexto.
- Inclua um resumo do que os resultados indicam e, quando relevante, uma interpretação
simples (por exemplo: totais, médias, top N, ausência de dados, etc.).
- Seja claro sobre quaisquer limitações (por exemplo: amostra limitada de linhas).
Expand All @@ -44,7 +44,11 @@ def nos_nodo_resposta(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI)
Não altera o status além de mantê-lo como 'aprovado'.
"""
status = estado.get("status", "")
pergunta = estado.get("pergunta_usuario", "")
pergunta = (
estado.get("pergunta_atual", "")
or estado.get("pergunta_original", "")
or estado.get("pergunta_usuario", "")
)
sql = estado.get("sql_gerada", "")
preview = estado.get("linhas_resultado_preview", [])
total = estado.get("total_linhas_resultado", None)
Expand Down
Loading
Loading