Saltar al contenido principal

SDK de Python

GRAIL es una librería primero. La CLI es un wrapper sobre las mismas dos clases públicas: GRAIL (modo KB) y MemoryProject (modo memoria).

Instalación

uv pip install -e .

Imports más comunes

from grail import (
GRAIL, # orquestador modo KB
MemoryProject, # orquestador modo memoria
Config, load_config, # configuración
LLMClient, # cliente LLM directo (avanzado)
EmbeddingClient, # cliente embeddings directo
PromptRegistry, # registro de prompts
Entity, Relationship, # schemas de rows del parquet
TextUnit, Community,
CommunityReport, Document,
SearchResult, # lo que devuelve search()
Reply, # lo que devuelve MemoryProject methods
)

Modo · Base de conocimiento
Clase GRAIL

Orquestador del modo base de conocimiento.

Construcción

from grail import GRAIL, load_config

config = load_config("./mi-kb") # path, dict o objeto Config
grail = GRAIL.from_config(config)

from_config arma todo: storage, registry de endpoints, cache de LLM, cost tracker, clientes LLM y embeddings, prompts, reranker opcional.

Atributos públicos post-construcción:

AtributoTipo
grail.configConfig
grail.storageStorageBackend
grail.llmLLMClient
grail.embeddingsEmbeddingClient
grail.promptsPromptRegistry
grail.cost_trackerCostTracker
grail.rerankerRerankerClient | None
grail.reporterReporter

Métodos

Todos los métodos de I/O son async. Desde un script normal, envuelve con asyncio.run(...). Desde código async (FastAPI, Textual, Jupyter), usa await directo.

MétodoFirmaPara qué
index()async () -> dictPipeline completo.
search()async (query, *, mode="local", ...) -> SearchResultUna sola búsqueda en cualquiera de 5 modos.
agent_search()async (query, ...) -> SearchResultBucle agéntico de tool-calls.
append()async (new_files: list[str]) -> dictAgregar archivos incrementalmente.
edit()async (replacements: dict[str, str]) -> dictReemplazar archivos.
delete()async (file_names: list[str]) -> dictEliminar archivos.
create_entity_types()async (*, sample_chars=8000, ...) -> list[str]Descubrir tipos de entidad.
status()sync () -> dictQué artefactos existen.

Ejemplo end-to-end

import asyncio
from grail import GRAIL, load_config

async def main():
grail = GRAIL.from_config(load_config("./mi-kb"))

# 1. Indexar
await grail.index()

# 2. Buscar
result = await grail.search(
"¿Quién es FONASA?",
mode="cascade",
use_reranker=True,
)
print(result.response)
print(result.completion_time, "segundos")
print(result.llm_calls, "llamadas LLM")

# 3. Agente
result = await grail.agent_search(
"Compara cobertura entre AUGE y Ley Ricarte Soto",
max_iterations=5,
)
print(result.response)

# 4. Incremental
await grail.append(["new_paper.pdf"])

# 5. Costo
print(grail.cost_tracker.render_total_cost())

asyncio.run(main())
kwargTipoDescripción
modestring"local" | "cascade" | "global" | "document"
conversation_historylist[{"role": "user", "content": "..."}, ...]
documentstring | NoneSolo para mode="document".
include_entity_nameslist[str]Restringir a estas entidades.
exclude_entity_nameslist[str]Excluir estas entidades.
use_rerankerbool | NoneNone = usar config.
artifact_instructionsstringTexto extra para el prompt de síntesis.

SearchResult

@dataclass
class SearchResult:
response: str | dict | list
context_data: str | list[DataFrame] | dict[str, DataFrame]
context_text: str | list[str] | dict[str, str]
completion_time: float
llm_calls: int

response es la respuesta legible. context_data y context_text son lo que vio el LLM (útil para debug).


Modo · Memoria agéntica
Clase MemoryProject

Orquestador del modo memoria agéntica. Same engine, different write path.

Construcción

from grail import MemoryProject

mp = MemoryProject("./mi-memoria")
# Opcionalmente: config=Config(...), embeddings=EmbeddingClient(...)

Si meta.json no existe en la carpeta, se crea automáticamente. Si existe, se abre.

Métodos principales

MétodoFirmaPara qué
add_observation()async (*, title, content, ...) -> ReplyEscribir una observación.
recall()async (query=None, *, mode="recall", ...) -> ReplyBúsqueda con filtros estructurales (cualquier modo).
list_observations()sync (*, category=None, since=None, ...) -> ReplyListar observaciones por filtro.
delete_observation()sync (slug, reason=None) -> ReplyBorrar una observación.
consolidate()sync () -> ReplyGenerar propuestas (sin mutar).
list_proposals()sync (*, status=None) -> ReplyListar propuestas pendientes/aplicadas.
apply_proposal()sync (proposal_id, *, accept=True) -> ReplyAceptar o rechazar una propuesta.

add_observation: la primitiva central

reply = await mp.add_observation(
title="...", # requerido
content="...", # cuerpo markdown
category="work/clients/acme", # path para folders-as-communities
tags=["decision"],
entities=[ # el agente declara entidades
{"name": "Acme", "type": "ORGANIZATION", "description": "..."},
],
relationships=[
{"source": "Acme", "target": "Postgres", "relationship_type": "CHOSE",
"description": "..."},
],
observed_at="2026-06-02T14:00:00Z", # default: now
confidence=0.95, # 0.0–1.0
source="architecture review",
related_to=["abc123", "def456"], # IDs de observaciones relacionadas
)

print(reply.ok) # True
print(reply.data["observation_id"]) # ULID asignado
print(reply.data["slug"]) # slug del archivo markdown
print(reply.data["file_path"]) # path absoluto
print(reply.data["new_entities"]) # nombres de las entidades nuevas
print(reply.data["updated_entities"]) # entidades existentes que se actualizaron

recall: filtros estructurales

# Modo recall puro (sin LLM, sin embedding)
reply = await mp.recall(
mode="recall",
since="7d",
category="work/clients/acme/**",
tags=["decision"],
entity_names=["Postgres"],
type="ORGANIZATION",
min_confidence=0.8,
limit=20,
)

for obs in reply.data["observations"]:
print(obs["observed_at"], obs["title"])
# Cascade con filtros (LLM + filtro estructural)
reply = await mp.recall(
"¿Por qué descartamos DynamoDB?",
mode="cascade",
since="30d",
category="work/clients/acme/**",
)
print(reply.data["response"])

El envoltorio Reply

Cada método de MemoryProject devuelve un Reply:

@dataclass
class Reply:
ok: bool
data: Any = None
warnings: list[str] = field(default_factory=list)
next_steps: list[str] = field(default_factory=list)
error: str | None = None

Es el mismo contrato JSON que emiten los scripts del skill. SDK y skill leen las mismas claves.


Configuración programática

Config es Pydantic. Lo puedes construir sin YAML:

from grail import Config
from grail.config import (
LLMConfig, EmbeddingsConfig, IndexingConfig, StorageConfig,
)

config = Config(
project_name="mi-proyecto",
root_dir="/tmp/grail-mi-proyecto",
llm=LLMConfig(
endpoint="openai",
model="gpt-4o-mini",
extra_pricing={"openai|gpt-4o-mini": [0.15, 0.60]},
),
embeddings=EmbeddingsConfig(
endpoint="openai",
model="text-embedding-3-small",
),
indexing=IndexingConfig(
entity_types=["PERSON", "ORGANIZATION", "PRODUCT"],
chunk_size=1500,
),
storage=StorageConfig(backend="local", root="/tmp/grail-mi-proyecto"),
)

grail = GRAIL.from_config(config)

O desde un dict:

config = Config.model_validate({
"project_name": "mi-proyecto",
"llm": {"endpoint": "openai", "model": "gpt-4o-mini"},
"embeddings": {"endpoint": "openai", "model": "text-embedding-3-small"},
})

Leer artefactos directamente

Cuando quieres parquet crudos, no respuesta de búsqueda:

from grail.query.retrieval import load_artifacts_for_search

artifacts = load_artifacts_for_search(grail.storage, grail._output_folder())

artifacts.documents # DataFrame
artifacts.text_units
artifacts.entities
artifacts.relationships
artifacts.communities
artifacts.community_reports
artifacts.nodes

Embeber en FastAPI

from fastapi import FastAPI
from grail import GRAIL, load_config

app = FastAPI()

@app.on_event("startup")
async def startup():
app.state.grail = GRAIL.from_config(load_config("./mi-kb"))

@app.post("/ask")
async def ask(question: str):
result = await app.state.grail.search(question, mode="cascade")
return {
"answer": result.response,
"llm_calls": result.llm_calls,
"completion_time": result.completion_time,
}

Una instancia GRAIL es segura de compartir entre requests — los clientes LLM/embeddings son async-safe y se rate-limitan internamente vía concurrent_requests.


Customizar colaboradores

Después de from_config, puedes reemplazar cualquier colaborador:

from grail.storage import StorageBackend

class MyGCSBackend(StorageBackend):
... # implementar los 7 métodos requeridos

grail = GRAIL.from_config(config)
grail.storage = MyGCSBackend(bucket="my-bucket")

Igual con reporter, vector store, reranker.

Siguiente paso