Grafos de conocimiento con conciencia temporal y recuperación multi-salto (parte 2 de 3)
3.2.7. Evento Temporal
El modelo TemporalEvent reúne el Statement y toda la información relacionada en una práctica clase. Es una salida principal del TemporalAgent y juega un papel importante dentro del InvalidationAgent.
Los campos principales incluyen:
id: Un identificador único para el eventochunk_id: Apunta alChunkespecífico asociado con el eventostatement: ElRawStatementespecífico extraído delChunkque detalla una relación o eventoembedding: Una representación delstatementutilizada por elInvalidationAgentpara medir la similitud del eventotriplets: Identificadores únicos para losTripletsindividuales extraídos delStatementvalid_at: Marca de tiempo que indica cuándo el evento se vuelve válidoinvalid_at: Marca de tiempo que indica cuándo el evento se vuelve inválidotemporal_type: Describe las características temporales delRawStatementstatement_type: Categoriza la declaración según elRawStatementoriginalcreated_at: Fecha en que el evento fue creado por primera vez.expired_at: Fecha en que el evento fue marcado como inválido (establecido encreated_atsiinvalid_atya está establecido al construir elTemporalEvent)invalidated_by: ID delTemporalEventresponsable de invalidar este evento, si aplica
import json
from pydantic import model_validator
class TemporalEvent(BaseModel):
"""Model representing a temporal event with statement, triplet, and validity information."""
id: uuid.UUID = Field(default_factory=uuid.uuid4)
chunk_id: uuid.UUID
statement: str
embedding: list[float] = Field(default_factory=lambda: [0.0] * 256)
triplets: list[uuid.UUID]
valid_at: datetime | None = None
invalid_at: datetime | None = None
temporal_type: TemporalType
statement_type: StatementType
created_at: datetime = Field(default_factory=datetime.now)
expired_at: datetime | None = None
invalidated_by: uuid.UUID | None = None
@property
def triplets_json(self) -> str:
"""Convert triplets list to JSON string."""
return json.dumps([str(t) for t in self.triplets]) if self.triplets else "[]"
@classmethod
def parse_triplets_json(cls, triplets_str: str) -> list[uuid.UUID]:
"""Parse JSON string back into list of UUIDs."""
if not triplets_str or triplets_str == "[]":
return []
return [uuid.UUID(t) for t in json.loads(triplets_str)]
@model_validator(mode="after")
def set_expired_at(self) -> "TemporalEvent":
"""Set expired_at if invalid_at is set and temporal_type is DYNAMIC."""
self.expired_at = self.created_at if (self.invalid_at is not None) and (self.temporal_type == TemporalType.DYNAMIC) else None
return self
3.2.8. Definiendo nuestro Agente Temporal
Ahora llegamos a un punto central en nuestro pipeline: la clase TemporalAgent. Esta reúne los pasos que hemos construido anteriormente: chunking, modelos de datos y prompts. Veamos más de cerca cómo funciona.
La función principal, extract_transcript_events, maneja todos los procesos clave:
- Extrae un
RawStatementde cadaChunk. - De cada
RawStatement, identifica elTemporalValidityRangejunto con listas de objetosTripletyEntityrelacionados. - Finalmente, agrupa toda esta información de manera ordenada en un
TemporalEventpara cadaRawStatement.
Esto es lo que obtendrás:
transcript: La transcripción que se está analizando actualmente.all_events: Una lista completa de todos los objetosTemporalEventgenerados.all_triplets: Una colección completa de objetosTripletextraídos de todos los eventos.all_entities: Una lista detallada de todos los objetosEntityextraídos de los eventos, que se refinarán en pasos posteriores.
El siguiente diagrama visualiza esta parte de nuestro pipeline:

import asyncio
from typing import Any
from jinja2 import DictLoader, Environment
from openai import AsyncOpenAI
from tenacity import retry, stop_after_attempt, wait_random_exponential
class TemporalAgent:
"""Handles temporal-based operations for extracting and processing temporal events from text."""
def __init__(self) -> None:
"""Initialize the TemporalAgent with a client."""
self._client = AsyncOpenAI()
self._model = "gpt-4.1-mini"
self._env = Environment(loader=DictLoader({
"statement_extraction.jinja": statement_extraction_prompt,
"date_extraction.jinja": date_extraction_prompt,
"triplet_extraction.jinja": triplet_extraction_prompt,
}))
self._env.filters["split_and_capitalize"] = self.split_and_capitalize
@staticmethod
def split_and_capitalize(value: str) -> str:
"""Split dict key string and reformat for jinja prompt."""
return " ".join(value.split("_")).capitalize()
async def get_statement_embedding(self, statement: str) -> list[float]:
"""Get the embedding of a statement."""
response = await self._client.embeddings.create(
model="text-embedding-3-large",
input=statement,
dimensions=256,
)
return response.data[0].embedding
@retry(wait=wait_random_exponential(multiplier=1, min=1, max=30), stop=stop_after_attempt(3))
async def extract_statements(
self,
chunk: Chunk,
inputs: dict[str, Any],
) -> RawStatementList:
"""Determine initial validity date range for a statement.
Args:
chunk (Chunk): The chunk of text to analyze.
inputs (dict[str, Any]): Additional input parameters for extraction.
Returns:
Statement: Statement with updated temporal range.
"""
inputs["chunk"] = chunk.text
template = self._env.get_template("statement_extraction.jinja")
prompt = template.render(
inputs=inputs,
definitions=LABEL_DEFINITIONS,
json_schema=RawStatementList.model_fields,
)
response = await self._client.responses.parse(
model=self._model,
temperature=0,
input=prompt,
text_format=RawStatementList,
)
raw_statements = response.output_parsed
statements = RawStatementList.model_validate(raw_statements)
return statements
@retry(wait=wait_random_exponential(multiplier=1, min=1, max=30), stop=stop_after_attempt(3))
async def extract_temporal_range(
self,
statement: RawStatement,
ref_dates: dict[str, Any],
) -> TemporalValidityRange:
"""Determine initial validity date range for a statement.
Args:
statement (Statement): Statement to analyze.
ref_dates (dict[str, Any]): Reference dates for the statement.
Returns:
Statement: Statement with updated temporal range.
"""
if statement.temporal_type == TemporalType.ATEMPORAL:
return TemporalValidityRange(valid_at=None, invalid_at=None)
template = self._env.get_template("date_extraction.jinja")
inputs = ref_dates | statement.model_dump()
prompt = template.render(
inputs=inputs,
temporal_guide={statement.temporal_type.value: LABEL_DEFINITIONS["temporal_labelling"][statement.temporal_type.value]},
statement_guide={statement.statement_type.value: LABEL_DEFINITIONS["episode_labelling"][statement.statement_type.value]},
json_schema=RawTemporalRange.model_fields,
)
response = await self._client.responses.parse(
model=self._model,
temperature=0,
input=prompt,
text_format=RawTemporalRange,
)
raw_validity = response.output_parsed
temp_validity = TemporalValidityRange.model_validate(raw_validity.model_dump()) if raw_validity else TemporalValidityRange()
if temp_validity.valid_at is None:
temp_validity.valid_at = inputs["publication_date"]
if statement.temporal_type == TemporalType.STATIC:
temp_validity.invalid_at = None
return temp_validity
@retry(wait=wait_random_exponential(multiplier=1, min=1, max=30), stop=stop_after_attempt(3))
async def extract_triplet(
self,
statement: RawStatement,
max_retries: int = 3,
) -> RawExtraction:
"""Extract triplets and entities from a statement as a RawExtraction object."""
template = self._env.get_template("triplet_extraction.jinja")
prompt = template.render(
statement=statement.statement,
json_schema=RawExtraction.model_fields,
predicate_instructions=PREDICATE_DEFINITIONS,
)
for attempt in range(max_retries):
try:
response = await self._client.responses.parse(
model=self._model,
temperature=0,
input=prompt,
text_format=RawExtraction,
)
raw_extraction = response.output_parsed
extraction = RawExtraction.model_validate(raw_extraction)
return extraction
except Exception as e:
if attempt == max_retries - 1:
raise
print(f"Attempt {attempt + 1} failed with error: {str(e)}. Retrying...")
await asyncio.sleep(1)
raise Exception("All retry attempts failed to extract triplets")
async def extract_transcript_events(
self,
transcript: Transcript,
) -> tuple[Transcript, list[TemporalEvent], list[Triplet], list[Entity]]:
"""
For each chunk in the transcript:
- Extract statements
- For each statement, extract temporal range and Extraction in parallel
- Build TemporalEvent for each statement
- Collect all events, triplets, and entities for later DB insertion
Returns the transcript, all events, all triplets, and all entities.
"""
if not transcript.chunks:
return transcript, [], [], []
doc_summary = {
"main_entity": transcript.company or None,
"document_type": "Earnings Call Transcript",
"publication_date": transcript.date,
"quarter": transcript.quarter,
"document_chunk": None,
}
all_events: list[TemporalEvent] = []
all_triplets: list[Triplet] = []
all_entities: list[Entity] = []
async def _process_chunk(chunk: Chunk) -> tuple[Chunk, list[TemporalEvent], list[Triplet], list[Entity]]:
statements_list = await self.extract_statements(chunk, doc_summary)
events: list[TemporalEvent] = []
chunk_triplets: list[Triplet] = []
chunk_entities: list[Entity] = []
async def _process_statement(statement: RawStatement) -> tuple[TemporalEvent, list[Triplet], list[Entity]]:
temporal_range_task = self.extract_temporal_range(statement, doc_summary)
extraction_task = self.extract_triplet(statement)
temporal_range, raw_extraction = await asyncio.gather(temporal_range_task, extraction_task)
# Create the event first to get its id
embedding = await self.get_statement_embedding(statement.statement)
event = TemporalEvent(
chunk_id=chunk.id,
statement=statement.statement,
embedding=embedding,
triplets=[],
valid_at=temporal_range.valid_at,
invalid_at=temporal_range.invalid_at,
temporal_type=statement.temporal_type,
statement_type=statement.statement_type,
)
# Map raw triplets/entities to Triplet/Entity with event_id
triplets = [Triplet.from_raw(rt, event.id) for rt in raw_extraction.triplets]
entities = [Entity.from_raw(re, event.id) for re in raw_extraction.entities]
event.triplets = [triplet.id for triplet in triplets]
return event, triplets, entities
if statements_list.statements:
results = await asyncio.gather(*(_process_statement(stmt) for stmt in statements_list.statements))
for event, triplets, entities in results:
events.append(event)
chunk_triplets.extend(triplets)
chunk_entities.extend(entities)
return chunk, events, chunk_triplets, chunk_entities
chunk_results = await asyncio.gather(*(_process_chunk(chunk) for chunk in transcript.chunks))
transcript.chunks = [chunk for chunk, _, _, _ in chunk_results]
for _, events, triplets, entities in chunk_results:
all_events.extend(events)
all_triplets.extend(triplets)
all_entities.extend(entities)
return transcript, all_events, all_triplets, all_entities
temporal_agent = TemporalAgent()
# transcripts: list[Transcript] = chunker.generate_transcripts_and_chunks(dataset)
# Process only the first transcript
results = await temporal_agent.extract_transcript_events(transcripts[0])
# Parse and display the results in a nice format
transcript, events, triplets, entities = results
print("=== TRANSCRIPT PROCESSING RESULTS ===\n")
print(f"📄 Transcript ID: {transcript.id}")
print(f"📊 Total Chunks: {len(transcript.chunks) if transcript.chunks is not None else 0}")
print(f"🎯 Total Events: {len(events)}")
print(f"🔗 Total Triplets: {len(triplets)}")
print(f"🏷️ Total Entities: {len(entities)}")
print("\n=== SAMPLE EVENTS ===")
for i, event in enumerate(events[:3]): # Show first 3 events
print(f"\n📝 Event {i+1}:")
print(f" Statement: {event.statement[:100]}...")
print(f" Type: {event.temporal_type}")
print(f" Valid At: {event.valid_at}")
print(f" Triplets: {len(event.triplets)}")
print("\n=== SAMPLE TRIPLETS ===")
for i, triplet in enumerate(triplets[:5]): # Show first 5 triplets
print(f"\n🔗 Triplet {i+1}:")
print(f" Subject: {triplet.subject_name} (ID: {triplet.subject_id})")
print(f" Predicate: {triplet.predicate}")
print(f" Object: {triplet.object_name} (ID: {triplet.object_id})")
if triplet.value:
print(f" Value: {triplet.value}")
print("\n=== SAMPLE ENTITIES ===")
for i, entity in enumerate(entities[:5]): # Show first 5 entities
print(f"\n🏷️ Entity {i+1}:")
print(f" Name: {entity.name}")
print(f" Type: {entity.type}")
print(f" Description: {entity.description}")
if entity.resolved_id:
print(f" Resolved ID: {entity.resolved_id}")
3.2.9. Resolución de Entidades
Antes de sumergirnos en la invalidación temporal, primero debemos abordar la resolución de entidades. Este proceso es crucial para asegurar que cada entidad del mundo real tenga una representación única y autorizada, eliminando duplicados y manteniendo la consistencia de los datos. Por ejemplo, AMD y Advanced Micro Devices claramente se refieren a la misma entidad, por lo que deben representarse bajo una entidad canónica unificada.
Aquí está nuestro enfoque para la resolución de entidades:
Usamos la clase
EntityResolutionpara agrupar entidades por tipo (Entity.type), lo que nos ayuda a hacer comparaciones específicas del contexto, como distinguir empresas de individuos.Para abordar los datos ruidosos de manera efectiva, aprovechamos RapidFuzz para agrupar entidades basándonos en la similitud de nombres. Este método implica una comparación simple, insensible a mayúsculas y minúsculas, sin puntuación, utilizando una relación de coincidencia parcial, lo que permite tolerancia a pequeños errores tipográficos y coincidencias de subcadenas.
Dentro de cada clúster de coincidencia difusa, seleccionamos el medoide, la entidad más representativa del clúster basándose en la similitud general. Esto evita el sesgo hacia la entidad más frecuente o la primera en la lista. El medoide sirve entonces como la entidad canónica inicial, proporcionando una representación semánticamente significativa del grupo.
Antes de añadir una nueva entidad canónica, verificamos el medoide con las canónicas existentes, considerando tanto la coincidencia difusa como los acrónimos. Por ejemplo,
Advanced Micro Devices Inc.puede producirAMDI, que coincide estrechamente con el acrónimoAMD. Este paso ayuda a prevenir la creación innecesaria de entidades canónicas duplicadas.Si no se encuentra una coincidencia global, el medoide se convierte en una nueva entidad canónica, con todas las entidades del clúster vinculadas a ella a través de un ID resuelto.
Finalmente, realizamos una verificación de seguridad adicional para resolver posibles duplicaciones de acrónimos en todas las entidades canónicas, asegurando una limpieza exhaustiva.
Para mejorar aún más la resolución de entidades, podrías considerar técnicas avanzadas como:
- Usar la similitud basada en embeddings en
Entity.descriptionjunto conEntity.name, mejorando la desambiguación más allá de la simple similitud de texto. - Emplear un modelo de lenguaje grande (LLM) para agrupar inteligentemente entidades bajo sus formas canónicas, mejorando la precisión a través de la comprensión semántica.
import sqlite3
import string
from rapidfuzz import fuzz
from db_interface import (
get_all_canonical_entities,
insert_canonical_entity,
remove_entity,
update_entity_references,
)
class EntityResolution:
"""
Entity resolution class.
"""
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self.global_canonicals: list[Entity] = get_all_canonical_entities(conn)
self.threshold = 80.0
self.acronym_thresh = 98.0
def resolve_entities_batch(
self, batch_entities: list[Entity],
) -> None:
"""
Orchestrate the scalable entity resolution workflow for a batch of entities.
"""
type_groups = {t: [e for e in batch_entities if e.type == t] for t in set(e.type for e in batch_entities)}
for entities in type_groups.values():
clusters = self.group_entities_by_fuzzy_match(entities)
for group in clusters.values():
if not group:
continue
local_canon = self.set_medoid_as_canonical_entity(group)
if local_canon is None:
continue
match = self.match_to_canonical_entity(local_canon, self.global_canonicals)
if " " in local_canon.name: # Multi-word entity
acronym = "".join(word[0] for word in local_canon.name.split())
acronym_match = next(
(c for c in self.global_canonicals if fuzz.ratio(acronym, c.name) >= self.acronym_thresh and " " not in c.name), None
)
if acronym_match:
match = acronym_match
if match:
canonical_id = match.id
else:
insert_canonical_entity(
self.conn,
{
"id": str(local_canon.id),
"name": local_canon.name,
"type": local_canon.type,
"description": local_canon.description,
},
)
canonical_id = local_canon.id
self.global_canonicals.append(local_canon)
for entity in group:
entity.resolved_id = canonical_id
self.conn.execute(
"UPDATE entities SET resolved_id = ? WHERE id = ?",
(str(canonical_id), str(entity.id))
)
# Clean up any acronym duplicates after processing all entities
self.merge_acronym_canonicals()
def group_entities_by_fuzzy_match(
self, entities: list[Entity],
) -> dict[str, list[Entity]]:
"""
Group entities by fuzzy name similarity using rapidfuzz"s partial_ratio.
Returns a mapping from canonical name to list of grouped entities.
"""
def clean(name: str) -> str:
return name.lower().strip().translate(str.maketrans("", "", string.punctuation))
name_to_entities: dict[str, list[Entity]] = {}
cleaned_name_map: dict[str, str] = {}
for entity in entities:
name_to_entities.setdefault(entity.name, []).append(entity)
cleaned_name_map[entity.name] = clean(entity.name)
unique_names = list(name_to_entities.keys())
clustered: dict[str, list[Entity]] = {}
used = set()
for name in unique_names:
if name in used:
continue
clustered[name] = []
for other_name in unique_names:
if other_name in used:
continue
score = fuzz.partial_ratio(cleaned_name_map[name], cleaned_name_map[other_name])
if score >= self.threshold:
clustered[name].extend(name_to_entities[other_name])
used.add(other_name)
return clustered
def set_medoid_as_canonical_entity(self, entities: list[Entity]) -> Entity | None:
"""
Select as canonical the entity in the group with the highest total similarity (sum of partial_ratio) to all others.
Returns the medoid entity or None if the group is empty.
"""
if not entities:
return None
def clean(name: str) -> str:
return name.lower().strip().translate(str.maketrans("", "", string.punctuation))
n = len(entities)
scores = [0.0] * n
for i in range(n):
for j in range(n):
if i != j:
s1 = clean(entities[i].name)
s2 = clean(entities[j].name)
scores[i] += fuzz.partial_ratio(s1, s2)
max_idx = max(range(n), key=lambda idx: scores[idx])
return entities[max_idx]
def match_to_canonical_entity(self, entity: Entity, canonical_entities: list[Entity]) -> Entity | None:
"""
Fuzzy match a single entity to a list of canonical entities.
Returns the best matching canonical entity or None if no match above self.threshold.
"""
def clean(name: str) -> str:
return name.lower().strip().translate(str.maketrans("", "", string.punctuation))
best_score: float = 0
best_canon = None
for canon in canonical_entities:
score = fuzz.partial_ratio(clean(entity.name), clean(canon.name))
if score > best_score:
best_score = score
best_canon = canon
if best_score >= self.threshold:
return best_canon
return None
def merge_acronym_canonicals(self) -> None:
"""
Merge canonical entities where one is an acronym of another.
"""
multi_word = [e for e in self.global_canonicals if " " in e.name]
single_word = [e for e in self.global_canonicals if " " not in e.name]
acronym_map = {}
for entity in multi_word:
acronym = "".join(word[0].upper() for word in entity.name.split())
acronym_map[entity.id] = acronym
for entity in multi_word:
acronym = acronym_map[entity.id]
for single_entity in single_word:
score = fuzz.ratio(acronym, single_entity.name)
if score >= self.threshold:
update_entity_references(self.conn, str(entity.id), str(single_entity.id))
remove_entity(self.conn, str(entity.id))
self.global_canonicals.remove(entity)
break
3.2.10. Agente de invalidación
Comprendiendo el Proceso de Invalidación
Para invalidar eventos temporales de manera efectiva, el agente realiza verificaciones en ambas direcciones:
- Entrantes vs. Existentes: ¿Los eventos entrantes son invalidados por eventos ya presentes?
- Existentes vs. Entrantes: ¿Los eventos actuales son invalidados por los nuevos eventos entrantes?
Esta evaluación bidireccional resulta en una clara decisión de Verdadero/Falso.
Prompt de Invalidación de Eventos
El prompt tiene tres componentes clave:
- Configuración de la Tarea
Define dos roles —primaryysecondary— para la comparación de eventos. La evaluación verifica si el eventoprimaryes invalidado por el eventosecondary. - Pautas
Proporciona criterios claros sobre cómo interpretar los metadatos temporales. Es importante destacar que la invalidación debe basarse únicamente en las relaciones explícitamente establecidas entre eventos. La información externa no puede influir en la decisión. - Información del Evento
Ambos eventos (primaryysecondary) incluyen detalles de marca de tiempo (valid_atyinvalid_at) junto con contexto semántico a través deStatement,Triplet, o ambos. Este contexto asegura comparaciones precisas y relevantes.
event_invalidation_prompt = """
Task: Analyze the primary event against the secondary event and determine if the primary event is invalidated by the secondary event.
Only set dates if they explicitly relate to the validity of the relationship described in the text.
IMPORTANT: Only invalidate events if they are directly invalidated by the other event given in the context. Do NOT use any external knowledge to determine validity ranges.
Only use dates that are directly stated to invalidate the relationship. The invalid_at for the invalidated event should be the valid_at of the event that caused the invalidation.
Invalidation Guidelines:
1. Dates are given in ISO 8601 format (YYYY-MM-DDTHH:MM:SS.SSSSSSZ).
2. Where invalid_at is null, it means this event is still valid and considered to be ongoing.
3. Where invalid_at is defined, the event has previously been invalidated by something else and can be considered "finished".
4. An event can refine the invalid_at of a finished event to an earlier date only.
5. An event cannot invalidate an event that chronologically occurred after it.
6. An event cannot be invalidated by an event that chronologically occurred before it.
7. An event cannot invalidate itself.
---
Primary Event:
{% if primary_event -%}
Statement: {{primary_event}}
{%- endif %}
{% if primary_triplet -%}
Triplet: {{primary_triplet}}
{%- endif %}
Valid_at: {{primary_event.valid_at}}
Invalid_at: {{primary_event.invalid_at}}
---
Secondary Event:
{% if secondary_event -%}
Statement: {{secondary_event}}
{%- endif %}
{% if secondary_triplet -%}
Triplet: {{secondary_triplet}}
{%- endif %}
Valid_at: {{secondary_event.valid_at}}
Invalid_at: {{secondary_event.invalid_at}}
---
Return: "True" if the primary event is invalidated or its invalid_at is refined else "False"
"""
Requisitos para ser comparados para la invalidación
Solo podemos invalidar hechos dinámicos que aún no han sido marcados como inválidos. Estos hechos sirven como nuestros eventos primarios, mientras que los posibles candidatos para la invalidación son nuestros eventos secundarios. Para agilizar el proceso de invalidación, considera estas pautas al evaluar eventos secundarios:
- Debe ser de tipo FACT y no Atemporal
- Compartir al menos una entidad canónica a nivel de triplete
- Pertenecer al mismo grupo de predicado semántico a nivel de triplete (definido a continuación)
- Superponerse temporalmente y estar actualmente en curso
- Tener una similitud de coseno de declaración por encima del umbral (actualmente establecido en 0.5)
- El umbral de similitud (0.5) nos ayuda a filtrar el ruido de manera efectiva seleccionando solo los
top_kresultados más relevantes. Las similitudes semánticas de bajo nivel son aceptables, ya que nuestro objetivo es refinar los datos enviados al LLM para una evaluación posterior
Cuando ocurre la invalidación, anotamos los eventos afectados con expired_at y invalidated_by para indicar claramente las relaciones de causa y efecto.
PREDICATE_GROUPS: list[list[str]] = [
["IS_A", "HAS_A", "LOCATED_IN", "HOLDS_ROLE", "PART_OF"],
["PRODUCES", "SELLS", "SUPPLIES", "DISCONTINUED", "SECURED"],
["LAUNCHED", "DEVELOPED", "ADOPTED_BY", "INVESTS_IN", "COLLABORATES_WITH"],
["HAS_REVENUE", "INCREASED", "DECREASED", "RESULTED_IN", "TARGETS"],
]
Cuando juntamos todo esto, el flujo de trabajo para nuestro InvalidationAgent se ve así:
-
Detección de Rango Temporal
Comenzamos identificando cuándo ocurren los eventos con
get_incoming_temporal_bounds(). Esta función verifica elvalid_atdel evento y, si es dinámico, suinvalid_at. Los eventos atemporales no se incluyen aquí. -
Selección de Eventos Temporales
Usamos
select_events_temporally()para filtrar eventos por:- Verificar si son estáticos o dinámicos.
- Determinar si sus rangos de tiempo se superponen con nuestro evento entrante.
- Manejar eventos dinámicos con cuidado, especialmente los "en curso" sin un
invalid_at, o eventos con varias superposiciones.
-
Filtrado de Similitud de Embeddings
Luego,
filter_by_embedding_similarity()compara eventos basándose en la similitud semántica:- Calcula la similitud de coseno entre embeddings.
- Los eventos por debajo de un umbral de similitud (
_similarity_threshold = 0.5) se filtran. - Mantenemos solo los K eventos más similares (
_top_k = 10).
-
Combinación de Filtros Temporales y Semánticos
Con
select_temporally_relevant_events_for_invalidation(), nosotros:- Aplicamos primero los filtros temporales.
- Luego aplicamos los filtros de similitud de embeddings.
- Esto nos da una lista refinada de eventos que probablemente interactúan o entran en conflicto con el entrante.
-
Decisión de Invalidación de Eventos (basada en LLM)
El
invalidation_step()basado en LLM (impulsado por GPT-4.1-mini) determina si el evento entrante invalida otro evento:- Si lo hace, actualizamos:
invalid_atpara que coincida con elvalid_atdel evento secundario.expired_atcon la marca de tiempo actual.invalidated_bycon el ID del evento secundario.
- Si lo hace, actualizamos:
-
Verificación Bidireccional de Eventos
Usamos
bi_directional_event_invalidation()para verificar:- Si el evento entrante invalida eventos existentes.
- Si los eventos existentes y posteriores invalidan el evento entrante, especialmente si el entrante es dinámico y actualmente válido.
-
Lógica de Deduplicación
Por último,
resolve_duplicate_invalidations()asegura una invalidación limpia:- Permite solo una invalidación por evento.
- Elige el tiempo de invalidación más temprano para evitar conflictos.
- Esto ayuda a gestionar el procesamiento por lotes de manera efectiva.
La invalidación a continuación representa esta parte de nuestro pipeline:

import asyncio
import logging
import pickle
import sqlite3
from collections import Counter, defaultdict
from collections.abc import Coroutine
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Any
from jinja2 import DictLoader, Environment
from openai import AsyncOpenAI
from scipy.spatial.distance import cosine
from tenacity import retry, stop_after_attempt, wait_random_exponential
class InvalidationAgent:
"""Handles temporal-based operations for extracting and processing temporal events from text."""
def __init__(self, max_workers: int = 5) -> None:
"""Initialize the TemporalAgent with a client."""
self.max_workers = max_workers
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self.logger = logging.getLogger(__name__)
self._client = AsyncOpenAI()
self._model = "gpt-4.1-mini"
self._similarity_threshold = 0.5
self._top_k = 10
self._env = Environment(loader=DictLoader({
"event_invalidation.jinja": event_invalidation_prompt,
}))
@staticmethod
def cosine_similarity(v1: list[float], v2: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
return float(1 - cosine(v1, v2))
@staticmethod
def get_incoming_temporal_bounds(
event: TemporalEvent,
) -> dict[str, datetime] | None:
"""Get temporal bounds of all temporal events associated with a statement."""
if (event.temporal_type == TemporalType.ATEMPORAL) or (event.valid_at is None):
return None
temporal_bounds = {"start": event.valid_at, "end": event.valid_at}
if event.temporal_type == TemporalType.DYNAMIC:
if event.invalid_at:
temporal_bounds["end"] = event.invalid_at
return temporal_bounds
def select_events_temporally(
self,
triplet_events: list[tuple[Triplet, TemporalEvent]],
temp_bounds: dict[str, datetime],
dynamic: bool = False,
) -> list[tuple[Triplet, TemporalEvent]]:
"""Select temporally relevant events (static or dynamic) based on temporal bounds.
Groups events into before, after, and overlapping categories based on their temporal bounds.
Args:
triplet_events: List of (Triplet, TemporalEvent) tuples to filter
temp_bounds: Dict with 'start' and 'end' datetime bounds
dynamic: If True, filter dynamic events; if False, filter static events
n_window: Number of events to include before and after bounds
Returns:
Dict with keys '{type}_before', '{type}_after', '{type}_overlap' where type is 'dynamic' or 'static'
"""
def _check_overlaps_dynamic(event: TemporalEvent, start: datetime, end: datetime) -> bool:
"""Check if the dynamic event overlaps with the temporal bounds of the incoming event."""
if event.temporal_type != TemporalType.DYNAMIC:
return False
event_start = event.valid_at or datetime.min
event_end = event.invalid_at
# 1. Event contains the start
if (event_end is not None) and (event_start <= start <= event_end):
return True
# 2. Ongoing event starts before the incoming start
if (event_end is None) and (event_start <= start):
return True
# 3. Event starts within the incoming interval
if start <= event_start <= end:
return True
return False
# Filter by temporal type
target_type = TemporalType.DYNAMIC if dynamic else TemporalType.STATIC
filtered_events = [(triplet, event) for triplet, event in triplet_events if event.temporal_type == target_type]
# Sort by valid_at timestamp
sorted_events = sorted(filtered_events, key=lambda te: te[1].valid_at or datetime.min)
start = temp_bounds["start"]
end = temp_bounds["end"]
if dynamic:
overlap: list[tuple[Triplet, TemporalEvent]] = [
(triplet, event) for triplet, event in sorted_events if _check_overlaps_dynamic(event, start, end)
]
else:
overlap = []
if start != end:
overlap = [(triplet, event) for triplet, event in sorted_events if event.valid_at and start <= event.valid_at <= end]
return overlap
def filter_by_embedding_similarity(
self,
reference_event: TemporalEvent,
candidate_pairs: list[tuple[Triplet, TemporalEvent]],
) -> list[tuple[Triplet, TemporalEvent]]:
"""Filter triplet-event pairs by embedding similarity."""
pairs_with_similarity = [
(triplet, event, self.cosine_similarity(reference_event.embedding, event.embedding)) for triplet, event in candidate_pairs
]
filtered_pairs = [
(triplet, event) for triplet, event, similarity in pairs_with_similarity if similarity >= self._similarity_threshold
]
sorted_pairs = sorted(filtered_pairs, key=lambda x: self.cosine_similarity(reference_event.embedding, x[1].embedding), reverse=True)
return sorted_pairs[: self._top_k]
def select_temporally_relevant_events_for_invalidation(
self,
incoming_event: TemporalEvent,
candidate_triplet_events: list[tuple[Triplet, TemporalEvent]],
) -> list[tuple[Triplet, TemporalEvent]] | None:
"""Select the temporally relevant events based on temporal range of incoming event."""
temporal_bounds = self.get_incoming_temporal_bounds(event=incoming_event)
if not temporal_bounds:
return None
# First apply temporal filtering - find overlapping events
selected_statics = self.select_events_temporally(
triplet_events=candidate_triplet_events,
temp_bounds=temporal_bounds,
)
selected_dynamics = self.select_events_temporally(
triplet_events=candidate_triplet_events,
temp_bounds=temporal_bounds,
dynamic=True,
)
# Then filter by semantic similarity
similar_static = self.filter_by_embedding_similarity(reference_event=incoming_event, candidate_pairs=selected_statics)
similar_dynamics = self.filter_by_embedding_similarity(reference_event=incoming_event, candidate_pairs=selected_dynamics)
return similar_static + similar_dynamics
@retry(wait=wait_random_exponential(multiplier=1, min=1, max=30), stop=stop_after_attempt(3))
async def invalidation_step(
self,
primary_event: TemporalEvent,
primary_triplet: Triplet,
secondary_event: TemporalEvent,
secondary_triplet: Triplet,
) -> TemporalEvent:
"""Check if primary event should be invalidated by secondary event.
Args:
primary_event: Event to potentially invalidate
primary_triplet: Triplet associated with primary event
secondary_event: Event that might cause invalidation
secondary_triplet: Triplet associated with secondary event
Returns:
TemporalEvent: Updated primary event (may have invalid_at and invalidated_by set)
"""
template = self._env.get_template("event_invalidation.jinja")
prompt = template.render(
primary_event=primary_event.statement,
primary_triplet=f"({primary_triplet.subject_name}, {primary_triplet.predicate}, {primary_triplet.object_name})",
primary_valid_at=primary_event.valid_at,
primary_invalid_at=primary_event.invalid_at,
secondary_event=secondary_event.statement,
secondary_triplet=f"({secondary_triplet.subject_name}, {secondary_triplet.predicate}, {secondary_triplet.object_name})",
secondary_valid_at=secondary_event.valid_at,
secondary_invalid_at=secondary_event.invalid_at,
)
response = await self._client.responses.parse(
model=self._model,
temperature=0,
input=prompt,
)
# Parse boolean response
response_bool = str(response).strip().lower() == "true" if response else False
if not response_bool:
return primary_event
# Create updated event with invalidation info
updated_event = primary_event.model_copy(
update={
"invalid_at": secondary_event.valid_at,
"expired_at": datetime.now(),
"invalidated_by": secondary_event.id,
}
)
return updated_event
async def bi_directional_event_invalidation(
self,
incoming_triplet: Triplet,
incoming_event: TemporalEvent,
existing_triplet_events: list[tuple[Triplet, TemporalEvent]],
) -> tuple[TemporalEvent, list[TemporalEvent]]:
"""Validate and update temporal information for triplet events with full bidirectional invalidation.
Args:
incoming_triplet: The new triplet
incoming_event: The new event associated with the triplet
existing_triplet_events: List of existing (triplet, event) pairs to validate against
Returns:
tuple[TemporalEvent, list[TemporalEvent]]: (updated_incoming_event, list_of_changed_existing_events)
"""
changed_existing_events: list[TemporalEvent] = []
updated_incoming_event = incoming_event
# Filter for dynamic events that can be invalidated
dynamic_events_to_check = [
(triplet, event) for triplet, event in existing_triplet_events if event.temporal_type == TemporalType.DYNAMIC
]
# 1. Check if incoming event invalidates existing dynamic events
if dynamic_events_to_check:
tasks = [
self.invalidation_step(
primary_event=existing_event,
primary_triplet=existing_triplet,
secondary_event=incoming_event,
secondary_triplet=incoming_triplet,
)
for existing_triplet, existing_event in dynamic_events_to_check
]
updated_events = await asyncio.gather(*tasks)
for original_pair, updated_event in zip(dynamic_events_to_check, updated_events, strict=True):
original_event = original_pair[1]
if (updated_event.invalid_at != original_event.invalid_at) or (
updated_event.invalidated_by != original_event.invalidated_by
):
changed_existing_events.append(updated_event)
# 2. Check if existing events invalidate the incoming dynamic event
if incoming_event.temporal_type == TemporalType.DYNAMIC and incoming_event.invalid_at is None:
# Only check events that occur after the incoming event
invalidating_events = [
(triplet, event)
for triplet, event in existing_triplet_events
if (incoming_event.valid_at and event.valid_at and incoming_event.valid_at < event.valid_at)
]
if invalidating_events:
tasks = [
self.invalidation_step(
primary_event=incoming_event,
primary_triplet=incoming_triplet,
secondary_event=existing_event,
secondary_triplet=existing_triplet,
)
for existing_triplet, existing_event in invalidating_events
]
updated_events = await asyncio.gather(*tasks)
# Find the earliest invalidation
valid_invalidations = [(e.invalid_at, e.invalidated_by) for e in updated_events if e.invalid_at is not None]
if valid_invalidations:
earliest_invalidation = min(valid_invalidations, key=lambda x: x[0])
updated_incoming_event = incoming_event.model_copy(
update={
"invalid_at": earliest_invalidation[0],
"invalidated_by": earliest_invalidation[1],
"expired_at": datetime.now(),
}
)
return updated_incoming_event, changed_existing_events
@staticmethod
def resolve_duplicate_invalidations(changed_events: list[TemporalEvent]) -> list[TemporalEvent]:
"""Resolve duplicate invalidations by selecting the most restrictive (earliest) invalidation.
When multiple incoming events invalidate the same existing event, we should apply
the invalidation that results in the shortest validity range (earliest invalid_at).
Args:
changed_events: List of events that may contain duplicates with different invalidations
Returns:
List of deduplicated events with the most restrictive invalidation applied
"""
if not changed_events:
return []
# Count occurrences of each event ID
id_counts = Counter(str(event.id) for event in changed_events)
resolved_events = []
# Group events by ID only for those with duplicates
events_by_id = defaultdict(list)
for event in changed_events:
event_id = str(event.id)
if id_counts[event_id] == 1:
resolved_events.append(event)
else:
events_by_id[event_id].append(event)
# Deduplicate only those with duplicates
for _id, event_versions in events_by_id.items():
invalidated_versions = [e for e in event_versions if e.invalid_at is not None]
if not invalidated_versions:
resolved_events.append(event_versions[0])
else:
most_restrictive = min(invalidated_versions, key=lambda e: (e.invalid_at if e.invalid_at is not None else datetime.max))
resolved_events.append(most_restrictive)
return resolved_events
async def _execute_task_pool(
self,
tasks: list[Coroutine[Any, Any, tuple[TemporalEvent, list[TemporalEvent]]]],
batch_size: int = 10
) -> list[Any]:
"""Execute tasks in batches using a pool to control concurrency.
Args:
tasks: List of coroutines to execute
batch_size: Number of tasks to process concurrently
Returns:
List of results from all tasks
"""
all_results = []
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
batch_results = await asyncio.gather(*batch, return_exceptions=True)
all_results.extend(batch_results)
# Small delay between batches to prevent overload
if i + batch_size < len(tasks):
await asyncio.sleep(0.1)
return all_results
async def process_invalidations_in_parallel(
self,
incoming_triplets: list[Triplet],
incoming_events: list[TemporalEvent],
existing_triplets: list[Triplet],
existing_events: list[TemporalEvent],
) -> tuple[list[TemporalEvent], list[TemporalEvent]]:
"""Process invalidations for multiple triplets in parallel.
Args:
incoming_triplets: List of new triplets to process
incoming_events: List of events associated with incoming triplets
existing_triplets: List of existing triplets from DB
existing_events: List of existing events from DB
Returns:
tuple[list[TemporalEvent], list[TemporalEvent]]:
- List of updated incoming events (potentially invalidated)
- List of existing events that were updated (deduplicated)
"""
# Create mappings for faster lookups
event_map = {str(e.id): e for e in existing_events}
incoming_event_map = {str(t.event_id): e for t, e in zip(incoming_triplets, incoming_events, strict=False)}
# Prepare tasks for parallel processing
tasks = []
for incoming_triplet in incoming_triplets:
incoming_event = incoming_event_map[str(incoming_triplet.event_id)]
# Get related triplet-event pairs
related_pairs = [
(t, event_map[str(t.event_id)])
for t in existing_triplets
if (str(t.subject_id) == str(incoming_triplet.subject_id) or str(t.object_id) == str(incoming_triplet.object_id))
and str(t.event_id) in event_map
]
# Filter for temporal relevance
all_relevant_events = self.select_temporally_relevant_events_for_invalidation(
incoming_event=incoming_event,
candidate_triplet_events=related_pairs,
)
if not all_relevant_events:
continue
# Add task for parallel processing
task = self.bi_directional_event_invalidation(
incoming_triplet=incoming_triplet,
incoming_event=incoming_event,
existing_triplet_events=all_relevant_events,
)
tasks.append(task)
# Process all invalidations in parallel with pooling
if not tasks:
return [], []
# Use pool size based on number of workers, but cap it
pool_size = min(self.max_workers * 2, 10) # Adjust these numbers based on your needs
results = await self._execute_task_pool(tasks, batch_size=pool_size)
# Collect all results (may contain duplicates)
updated_incoming_events = []
all_changed_existing_events = []
for result in results:
if isinstance(result, Exception):
self.logger.error(f"Task failed with error: {str(result)}")
continue
updated_event, changed_events = result
updated_incoming_events.append(updated_event)
all_changed_existing_events.extend(changed_events)
# Resolve duplicate invalidations for existing events
deduplicated_existing_events = self.resolve_duplicate_invalidations(all_changed_existing_events)
# Resolve duplicate invalidations for incoming events (in case multiple triplets from same event)
deduplicated_incoming_events = self.resolve_duplicate_invalidations(updated_incoming_events)
return deduplicated_incoming_events, deduplicated_existing_events
@staticmethod
def batch_fetch_related_triplet_events(
conn: sqlite3.Connection,
incoming_triplets: list[Triplet],
) -> tuple[list[Triplet], list[TemporalEvent]]:
"""
Batch fetch all existing triplets and their events from the DB that are related to any of the incoming triplets.
Related means:
- Share a subject or object entity
- Predicate is in the same group
- Associated event is a FACT
Returns two lists: triplets and events (with mapping via event_id).
"""
# 1. Build sets of all relevant entity IDs and predicate groups
entity_ids = set()
predicate_to_group = {}
for group in PREDICATE_GROUPS:
group_list = list(group)
for pred in group_list:
predicate_to_group[pred] = group_list
relevant_predicates = set()
for triplet in incoming_triplets:
entity_ids.add(str(triplet.subject_id))
entity_ids.add(str(triplet.object_id))
group = predicate_to_group.get(str(triplet.predicate), [])
if group:
relevant_predicates.update(group)
# 2. Prepare SQL query
entity_placeholders = ",".join(["?"] * len(entity_ids))
predicate_placeholders = ",".join(["?"] * len(relevant_predicates))
query = f"""
SELECT
t.id,
t.subject_name,
t.subject_id,
t.predicate,
t.object_name,
t.object_id,
t.value,
t.event_id,
e.chunk_id,
e.statement,
e.triplets,
e.statement_type,
e.temporal_type,
e.valid_at,
e.invalid_at,
e.created_at,
e.expired_at,
e.invalidated_by,
e.embedding
FROM triplets t
JOIN events e ON t.event_id = e.id
WHERE
(t.subject_id IN ({entity_placeholders}) OR t.object_id IN ({entity_placeholders}))
AND t.predicate IN ({predicate_placeholders})
AND e.statement_type = ?
"""
params = list(entity_ids) + list(entity_ids) + list(relevant_predicates) + [StatementType.FACT]
cursor = conn.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
triplets = []
events = []
events_by_id = {}
for row in rows:
triplet = Triplet(
id=row[0],
subject_name=row[1],
subject_id=row[2],
predicate=Predicate(row[3]),
object_name=row[4],
object_id=row[5],
value=row[6],
event_id=row[7],
)
event_id = row[7]
triplets.append(triplet)
if event_id not in events_by_id:
events_by_id[event_id] = TemporalEvent(
id=row[7],
chunk_id=row[8],
statement=row[9],
triplets=TemporalEvent.parse_triplets_json(row[10]),
statement_type=row[11],
temporal_type=row[12],
valid_at=row[13],
invalid_at=row[14],
created_at=row[15],
expired_at=row[16],
invalidated_by=row[17],
embedding=pickle.loads(row[18]) if row[18] else [0] * 1536,
)
events = list(events_by_id.values())
return triplets, events
Podemos crear una función de procesamiento por lotes para la invalidación de un conjunto de Eventos Temporales. Aquí es donde filtramos nuestras Declaraciones al tipo FACT antes de pasarlas al agente de invalidación para su procesamiento.
async def batch_process_invalidation(
conn: sqlite3.Connection, all_events: list[TemporalEvent], all_triplets: list[Triplet], invalidation_agent: InvalidationAgent
) -> tuple[list[TemporalEvent], list[TemporalEvent]]:
"""Process invalidation for all FACT events that are temporal.
Args:
conn: SQLite database connection
all_events: List of all extracted events
all_triplets: List of all extracted triplets
invalidation_agent: The invalidation agent instance
Returns:
tuple[list[TemporalEvent], list[TemporalEvent]]:
- final_events: All events (updated incoming events)
- events_to_update: Existing events that need DB updates
"""
def _get_fact_triplets(
all_events: list[TemporalEvent],
all_triplets: list[Triplet],
) -> list[Triplet]:
"""
Return only those triplets whose associated event is of statement_type FACT.
"""
fact_event_ids = {
event.id for event in all_events if (event.statement_type == StatementType.FACT) and (event.temporal_type != TemporalType.ATEMPORAL)
}
return [triplet for triplet in all_triplets if triplet.event_id in fact_event_ids]
# Prepare a list of triplets whose associated event is a FACT and not ATEMPORAL
fact_triplets = _get_fact_triplets(all_events, all_triplets)
if not fact_triplets:
return all_events, []
# Create event map for quick lookup
all_events_map = {event.id: event for event in all_events}
# Build aligned lists of valid triplets and their corresponding events
fact_events: list[TemporalEvent] = []
valid_fact_triplets: list[Triplet] = []
for triplet in fact_triplets:
# Handle potential None event_id and ensure type safety
if triplet.event_id is not None:
event = all_events_map.get(triplet.event_id)
if event:
fact_events.append(event)
valid_fact_triplets.append(triplet)
else:
print(f"Warning: Could not find event for fact_triplet with event_id {triplet.event_id}")
else:
print(f"Warning: Fact triplet {triplet.id} has no event_id, skipping invalidation")
if not valid_fact_triplets:
return all_events, []
# Batch fetch all related existing triplets and events
existing_triplets, existing_events = invalidation_agent.batch_fetch_related_triplet_events(conn, valid_fact_triplets)
# Process all invalidations in parallel
updated_incoming_fact_events, changed_existing_events = await invalidation_agent.process_invalidations_in_parallel(
incoming_triplets=valid_fact_triplets,
incoming_events=fact_events,
existing_triplets=existing_triplets,
existing_events=existing_events,
)
# Create mapping for efficient updates
updated_incoming_event_map = {event.id: event for event in updated_incoming_fact_events}
# Reconstruct final events list with updates applied
final_events = []
for original_event in all_events:
if original_event.id in updated_incoming_event_map:
final_events.append(updated_incoming_event_map[original_event.id])
else:
final_events.append(original_event)
return final_events, changed_existing_events
3.2.11. Armándolo todo
Ahora que hemos construido cada componente individual del flujo de trabajo del Grafo de Conocimiento Temporal, podemos integrarlos en un flujo de trabajo cohesivo.
Dado un transcripto dividido en chunks, el Agente Temporal procesa secuencialmente cada chunk, extrayendo inicialmente las declaraciones relevantes. Estas declaraciones son luego clasificadas y enriquecidas a través de fases de extracción posteriores, lo que resulta en Eventos Temporales, Tripletes estructurados y Entidades identificadas.
Las Entidades extraídas se cotejan con los registros existentes en la base de datos, asegurando una resolución precisa y evitando la redundancia. Después de la resolución de entidades, los Hechos Dinámicos se validan a través del Agente de Invalidación para verificar la consistencia y validez temporal.
Después de un procesamiento y validación exitosos, los datos refinados se almacenan sistemáticamente en sus respectivas tablas dentro de la base de datos SQLite, manteniendo un grafo de conocimiento organizado y temporalmente preciso.
Para ayudar a visualizar el código presentado a continuación, podemos volver a ver el diagrama del pipeline:

import sqlite3
from db_interface import (
has_events,
insert_chunk,
insert_entity,
insert_event,
insert_transcript,
insert_triplet,
update_events_batch,
)
from utils import safe_iso
async def ingest_transcript(
transcript: Transcript,
conn: sqlite3.Connection,
temporal_agent: TemporalAgent,
invalidation_agent: InvalidationAgent,
entity_resolver: EntityResolution) -> None:
"""
Ingest a Transcript object into the database, extracting and saving all chunks, events, triplets, and entities.
"""
insert_transcript(
conn,
{
"id": str(transcript.id),
"text": transcript.text,
"company": transcript.company,
"date": transcript.date,
"quarter": transcript.quarter,
},
)
transcript, all_events, all_triplets, all_entities = await temporal_agent.extract_transcript_events(transcript)
entity_resolver.resolve_entities_batch(all_entities)
name_to_canonical = {entity.name: entity.resolved_id for entity in all_entities if entity.resolved_id}
# Update triplets with resolved entity IDs
for triplet in all_triplets:
if triplet.subject_name in name_to_canonical:
triplet.subject_id = name_to_canonical[triplet.subject_name]
if triplet.object_name in name_to_canonical:
triplet.object_id = name_to_canonical[triplet.object_name]
# Invalidation processing with properly resolved triplet IDs
events_to_update: list[TemporalEvent] = []
if has_events(conn):
all_events, events_to_update = await batch_process_invalidation(conn, all_events, all_triplets, invalidation_agent)
# ALL DB operations happen in single transaction
with conn:
# Update existing events first (they're already in DB)
if events_to_update:
update_events_batch(conn, events_to_update)
print(f"Updated {len(events_to_update)} existing events")
# Insert new data
for chunk in transcript.chunks or []:
chunk_dict = chunk.model_dump()
insert_chunk(
conn,
{
"id": str(chunk_dict["id"]),
"transcript_id": str(transcript.id),
"text": chunk_dict["text"],
"metadata": json.dumps(chunk_dict["metadata"]),
},
)
for event in all_events:
event_dict = {
"id": str(event.id),
"chunk_id": str(event.chunk_id),
"statement": event.statement,
"embedding": pickle.dumps(event.embedding) if event.embedding is not None else None,
"triplets": event.triplets_json,
"statement_type": event.statement_type.value if hasattr(event.statement_type, "value") else event.statement_type,
"temporal_type": event.temporal_type.value if hasattr(event.temporal_type, "value") else event.temporal_type,
"created_at": safe_iso(event.created_at),
"valid_at": safe_iso(event.valid_at),
"expired_at": safe_iso(event.expired_at),
"invalid_at": safe_iso(event.invalid_at),
"invalidated_by": str(event.invalidated_by) if event.invalidated_by else None,
}
insert_event(conn, event_dict)
for triplet in all_triplets:
try:
insert_triplet(
conn,
{
"id": str(triplet.id),
"event_id": str(triplet.event_id),
"subject_name": triplet.subject_name,
"subject_id": str(triplet.subject_id),
"predicate": triplet.predicate,
"object_name": triplet.object_name,
"object_id": str(triplet.object_id),
"value": triplet.value,
},
)
except KeyError as e:
print(f"KeyError: {triplet.subject_name} or {triplet.object_name} not found in name_to_canonical")
print(f"Skipping triplet: Entity '{e.args[0]}' is unresolved.")
continue
# Deduplicate entities by id before insert
unique_entities = {}
for entity in all_entities:
unique_entities[str(entity.id)] = entity
for entity in unique_entities.values():
insert_entity(conn, {"id": str(entity.id), "name": entity.name, "resolved_id": str(entity.resolved_id)})
return None
# Initialize core components
sqlite_conn = make_connection(memory=False, refresh=True)
temporal_agent = TemporalAgent()
invalidation_agent = InvalidationAgent()
entity_resolver = EntityResolution(sqlite_conn)
# Ingest single transcript
await ingest_transcript(transcripts[0], sqlite_conn, temporal_agent, invalidation_agent, entity_resolver)
# View what tables have been created and populated
sqlite_conn.execute("SELECT name FROM sqlite_master WHERE type='table';").fetchall()
# View triplets table
from db_interface import view_db_table
triplets_df = view_db_table(sqlite_conn, "triplets", max_rows=10)
display(triplets_df)
Luego podemos ingerir el resto de los Transcripts. Ten en cuenta que este código no ha sido optimizado para estar listo para producción y, en promedio, tarda de 2 a 5 minutos por Transcript. Esta ingesta masiva utilizando los datos en /transcripts (~30 archivos) tardará hasta 2 horas en ejecutarse. Optimizar esto es un paso crítico para escalar a producción. Describimos algunos métodos que puedes usar para abordar esto en la sección del Apéndice A.3, "Implementando Concurrencia en el Pipeline de Ingesta", incluyendo chunking por lotes, agrupación de entidades y más.
import time
from tqdm import tqdm
async def bulk_transcript_ingestion(transcripts: list[Transcript], sqlite_conn: sqlite3.Connection) -> None:
"""Handle transcript ingestion with duplicate checking, optional overwriting, and progress tracking.
Args:
transcripts (List[Transcript]): List of transcripts to ingest
sqlite_conn (sqlite3.Connection): SQLite database connection
overwrite (bool, optional): Whether to overwrite existing transcripts. Defaults to False.
"""
temporal_agent = TemporalAgent()
invalidation_agent = InvalidationAgent()
entity_resolver = EntityResolution(sqlite_conn)
pbar = tqdm(total=len(transcripts), desc="Ingesting transcripts")
for transcript in transcripts:
start_time = time.time()
try:
await ingest_transcript(transcript, sqlite_conn, temporal_agent, invalidation_agent, entity_resolver)
# Calculate and display ingestion time
end_time = time.time()
ingestion_time = end_time - start_time
# Update progress bar with completion message
pbar.write(
f"Ingested transcript {transcript.id} "
f"in {ingestion_time:.2f} seconds"
)
except Exception as e:
pbar.write(f"Error ingesting transcript {transcript.id}: {str(e)}")
finally:
# Update progress bar
pbar.update(1)
pbar.close()
Nota: Ejecutar la siguiente celda para todos los transcripts de este conjunto de datos puede tomar aproximadamente 1 hora
# Bulk ingestion (not recommended)
sqlite_conn = make_connection(memory=False, refresh=True, db_path="my_database.db")
transcripts = load_transcripts_from_pickle()
# await bulk_transcript_ingestion(transcripts, sqlite_conn)
Te recomendamos cargar los datos preprocesados de AMD y NVDA desde un archivo creando una nueva conexión SQLite usando el código a continuación. Esto creará la base de datos necesaria para construir el grafo y el recuperador.
Puedes encontrar estos datos en HuggingFace.
from cb_functions import load_db_from_hf
sqlite_conn = load_db_from_hf()
Loading transcripts...
Loading chunks...
Loading events...
Loading triplets...
Loading entities...
✅ All tables written to SQLite.
# View transcripts table
from db_interface import view_db_table
transcript_df = view_db_table(sqlite_conn, "transcripts", max_rows=None)
display(transcript_df)
id \
0 f2f5aa4c-ad2b-4ed5-9792-bcbddbc4e207
1 74d42583-b614-4771-80c8-1ddf964a4f1c
2 26e523aa-7e15-4741-986a-6ec0be034a33
3 74380d19-203a-48f6-a1c8-d8df33aae362
4 7d620d30-7b09-4774-bc32-51b00a80badf
5 1ba2fc55-a121-43d4-85d7-e221851f2c7f
6 db1925df-b5a5-4cb2-862b-df269f53be7e
7 fe212bc0-9b3d-44ed-91ca-bfb856b21aa6
8 7c0a6f9c-9279-4714-b25e-8be20ae8fb99
9 10f95617-e5b2-4525-a207-cec9ae9a3211
10 aab926b2-5a23-4b39-a29c-c1e7ceef5a55
11 6d45f413-3aa5-4c76-b3cf-d0fdb0a03787
12 ad10e284-d209-42f1-8a7c-8c889af0914e
13 a30da2d4-3327-432e-9ce0-b57795a0fe26
14 038e0986-a689-4374-97d2-651b05bdfae8
15 6ff24a98-ad3b-4013-92eb-45ac5b0f214d
16 34d010f1-7221-4ed4-92f4-c69c4a3fd779
17 e5e31dd4-2587-40af-8f8c-56a772831acd
18 60e56971-9ab8-4ebd-ac2a-e9fce301ca33
19 1d4b2c13-4bf0-4c0f-90fe-a48c6e03c73a
20 b6b5df13-4736-4ecd-9c41-cf62f4639a4a
21 43094307-3f8f-40a2-886b-f4f1da64312c
22 e6902113-4b71-491d-b7de-8ff347b481cd
23 dbaa7a7c-1db2-4b0c-9130-8ca48f10be6f
24 6ec75a2d-d449-4f52-bb93-17b1770dbf6c
25 bcf360a8-0784-4c31-8a09-ca824a26264f
26 01d2252f-10a2-48f7-8350-ffe17bb8e18d
27 d4c10451-d7b2-4c13-8f15-695596e49144
28 6c832314-d5ef-42cd-9fa0-914c5480d7be
29 1207115e-20ed-479c-a903-e28dfda52ebd
30 259fe893-9d28-4e4d-bc55-2edf646e150b
31 02b1212b-cd3f-4c19-8505-8d1aea6d3ae2
32 fa199b2c-1f58-4663-af8c-29c531fc97d6
text company \
0 \n\nRefinitiv StreetEvents Event Transcript\nE... NVDA
1 \n\nThomson Reuters StreetEvents Event Transcr... AMD
2 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
3 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
4 \n\nThomson Reuters StreetEvents Event Transcr... AMD
5 \n\nThomson Reuters StreetEvents Event Transcr... AMD
6 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
7 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
8 \n\nThomson Reuters StreetEvents Event Transcr... AMD
9 \n\nThomson Reuters StreetEvents Event Transcr... AMD
10 \n\nThomson Reuters StreetEvents Event Transcr... AMD
11 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
12 \n\nThomson Reuters StreetEvents Event Transcr... AMD
13 \n\nThomson Reuters StreetEvents Event Transcr... AMD
14 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
15 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
16 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
17 \n\nThomson Reuters StreetEvents Event Transcr... AMD
18 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
19 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
20 \n\nThomson Reuters StreetEvents Event Transcr... AMD
21 \n\nThomson Reuters StreetEvents Event Transcr... AMD
22 \n\nThomson Reuters StreetEvents Event Transcr... AMD
23 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
24 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
25 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
26 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
27 \n\nThomson Reuters StreetEvents Event Transcr... AMD
28 \n\nThomson Reuters StreetEvents Event Transcr... AMD
29 \n\nThomson Reuters StreetEvents Event Transcr... AMD
30 \n\nRefinitiv StreetEvents Event Transcript\nE... AMD
31 \n\nThomson Reuters StreetEvents Event Transcr... NVDA
32 \n\nThomson Reuters StreetEvents Event Transcr... AMD
date quarter
0 2020-08-19T00:00:00 Q2 2021
1 2016-07-21T00:00:00 Q2 2016
2 2016-11-10T00:00:00 Q3 2017
3 2018-05-10T00:00:00 Q1 2019
4 2017-07-25T00:00:00 Q2 2017
5 2017-01-31T00:00:00 Q4 2016
6 2017-11-09T00:00:00 Q3 2018
7 2019-02-14T00:00:00 Q4 2019
8 2019-04-30T00:00:00 Q1 2019
9 2019-01-29T00:00:00 Q4 2018
10 2020-04-28T00:00:00 Q1 2020
11 2019-08-15T00:00:00 Q2 2020
12 2019-10-29T00:00:00 Q3 2019
13 2018-04-25T00:00:00 Q1 2018
14 2018-11-15T00:00:00 Q3 2019
15 2016-02-17T00:00:00 Q4 2016
16 2020-02-13T00:00:00 Q4 2020
17 2017-10-24T00:00:00 Q3 2017
18 2016-08-11T00:00:00 Q2 2017
19 2018-08-16T00:00:00 Q2 2019
20 2016-04-21T00:00:00 Q1 2016
21 2017-05-01T00:00:00 Q1 2017
22 2018-07-25T00:00:00 Q2 2018
23 2017-02-09T00:00:00 Q4 2017
24 2018-02-08T00:00:00 Q4 2018
25 2017-05-09T00:00:00 Q1 2018
26 2016-05-12T00:00:00 Q1 2017
27 2016-10-20T00:00:00 Q3 2016
28 2016-01-19T00:00:00 Q4 2015
29 2018-01-30T00:00:00 Q4 2017
30 2020-07-28T00:00:00 Q2 2020
31 2020-05-21T00:00:00 Q1 2021
32 2019-07-30T00:00:00 Q2 2019
| id | text | company | date | quarter | |
|---|---|---|---|---|---|
| 0 | f2f5aa4c-ad2b-4ed5-9792-bcbddbc4e207 | \n\nRefinitiv StreetEvents Event Transcript\nE... | NVDA | 2020-08-19T00:00:00 | Q2 2021 |
| 1 | 74d42583-b614-4771-80c8-1ddf964a4f1c | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2016-07-21T00:00:00 | Q2 2016 |
| 2 | 26e523aa-7e15-4741-986a-6ec0be034a33 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2016-11-10T00:00:00 | Q3 2017 |
| 3 | 74380d19-203a-48f6-a1c8-d8df33aae362 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2018-05-10T00:00:00 | Q1 2019 |
| 4 | 7d620d30-7b09-4774-bc32-51b00a80badf | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2017-07-25T00:00:00 | Q2 2017 |
| 5 | 1ba2fc55-a121-43d4-85d7-e221851f2c7f | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2017-01-31T00:00:00 | Q4 2016 |
| 6 | db1925df-b5a5-4cb2-862b-df269f53be7e | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2017-11-09T00:00:00 | Q3 2018 |
| 7 | fe212bc0-9b3d-44ed-91ca-bfb856b21aa6 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2019-02-14T00:00:00 | Q4 2019 |
| 8 | 7c0a6f9c-9279-4714-b25e-8be20ae8fb99 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2019-04-30T00:00:00 | Q1 2019 |
| 9 | 10f95617-e5b2-4525-a207-cec9ae9a3211 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2019-01-29T00:00:00 | Q4 2018 |
| 10 | aab926b2-5a23-4b39-a29c-c1e7ceef5a55 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2020-04-28T00:00:00 | Q1 2020 |
| 11 | 6d45f413-3aa5-4c76-b3cf-d0fdb0a03787 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2019-08-15T00:00:00 | Q2 2020 |
| 12 | ad10e284-d209-42f1-8a7c-8c889af0914e | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2019-10-29T00:00:00 | Q3 2019 |
| 13 | a30da2d4-3327-432e-9ce0-b57795a0fe26 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2018-04-25T00:00:00 | Q1 2018 |
| 14 | 038e0986-a689-4374-97d2-651b05bdfae8 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2018-11-15T00:00:00 | Q3 2019 |
| 15 | 6ff24a98-ad3b-4013-92eb-45ac5b0f214d | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2016-02-17T00:00:00 | Q4 2016 |
| 16 | 34d010f1-7221-4ed4-92f4-c69c4a3fd779 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2020-02-13T00:00:00 | Q4 2020 |
| 17 | e5e31dd4-2587-40af-8f8c-56a772831acd | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2017-10-24T00:00:00 | Q3 2017 |
| 18 | 60e56971-9ab8-4ebd-ac2a-e9fce301ca33 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2016-08-11T00:00:00 | Q2 2017 |
| 19 | 1d4b2c13-4bf0-4c0f-90fe-a48c6e03c73a | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2018-08-16T00:00:00 | Q2 2019 |
| 20 | b6b5df13-4736-4ecd-9c41-cf62f4639a4a | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2016-04-21T00:00:00 | Q1 2016 |
| 21 | 43094307-3f8f-40a2-886b-f4f1da64312c | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2017-05-01T00:00:00 | Q1 2017 |
| 22 | e6902113-4b71-491d-b7de-8ff347b481cd | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2018-07-25T00:00:00 | Q2 2018 |
| 23 | dbaa7a7c-1db2-4b0c-9130-8ca48f10be6f | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2017-02-09T00:00:00 | Q4 2017 |
| 24 | 6ec75a2d-d449-4f52-bb93-17b1770dbf6c | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2018-02-08T00:00:00 | Q4 2018 |
| 25 | bcf360a8-0784-4c31-8a09-ca824a26264f | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2017-05-09T00:00:00 | Q1 2018 |
| 26 | 01d2252f-10a2-48f7-8350-ffe17bb8e18d | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2016-05-12T00:00:00 | Q1 2017 |
| 27 | d4c10451-d7b2-4c13-8f15-695596e49144 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2016-10-20T00:00:00 | Q3 2016 |
| 28 | 6c832314-d5ef-42cd-9fa0-914c5480d7be | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2016-01-19T00:00:00 | Q4 2015 |
| 29 | 1207115e-20ed-479c-a903-e28dfda52ebd | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2018-01-30T00:00:00 | Q4 2017 |
| 30 | 259fe893-9d28-4e4d-bc55-2edf646e150b | \n\nRefinitiv StreetEvents Event Transcript\nE... | AMD | 2020-07-28T00:00:00 | Q2 2020 |
| 31 | 02b1212b-cd3f-4c19-8505-8d1aea6d3ae2 | \n\nThomson Reuters StreetEvents Event Transcr... | NVDA | 2020-05-21T00:00:00 | Q1 2021 |
| 32 | fa199b2c-1f58-4663-af8c-29c531fc97d6 | \n\nThomson Reuters StreetEvents Event Transcr... | AMD | 2019-07-30T00:00:00 | Q2 2019 |
3.3. Grafos de Conocimiento
3.3.1 Construyendo nuestro Grafo de Conocimiento con NetworkX
Al construir el grafo de conocimiento, los identificadores de entidad canónica derivados de los tripletes aseguran un mapeo preciso de los nombres de las entidades, permitiendo el almacenamiento de metadatos temporales detallados directamente en los bordes. Específicamente, la implementación utiliza los atributos:
- valid_at, invalid_at y temporal_type para la Validez Temporal, que representa la precisión del mundo real en momentos históricos específicos, crucial para el análisis de hechos históricos.
- Opcionalmente, los atributos created_at y expired_at también pueden usarse para la Validez Transaccional, lo que permite auditorías y atribución de fuentes al rastrear cuándo se registró, actualizó o corrigió la información.
La validez transaccional es particularmente beneficiosa en escenarios como:
- Finanzas: Determinar los hechos financieros aceptados sobre el balance de la Compañía X en una fecha histórica específica, basándose en el conocimiento aceptado contemporáneamente.
- Derecho: Identificar los marcos legales aplicables tal como se entendían en la fecha de firma de un contrato, o las obligaciones de cumplimiento reconocidas en fechas pasadas.
- Periodismo: Evaluar si la información previamente reportada ha quedado obsoleta, asegurando que los comunicados de prensa y los informes sigan siendo precisos y creíbles a lo largo del tiempo.
import numpy
import pandas
import scipy
print("numpy :", numpy.__version__)
print("pandas:", pandas.__version__)
print("scipy :", scipy.__version__)
from cb_functions import build_graph, load_db_from_hf
conn = load_db_from_hf()
G = build_graph(conn)
print(G.number_of_nodes(), "nodes,", G.number_of_edges(), "edges")
Loading transcripts...
✅ All tables written to SQLite.
Loading chunks...
✅ All tables written to SQLite.
Loading events...
✅ All tables written to SQLite.
Loading triplets...
✅ All tables written to SQLite.
Loading entities...
✅ All tables written to SQLite.
2282 nodes, 13150 edges
import networkx as nx
# Print descriptive notes about the graph
print(f"Graph has {G.number_of_nodes()} nodes and {G.number_of_edges()} edges")
# Get some basic graph statistics
print(f"Graph density: {G.number_of_edges() / (G.number_of_nodes() * (G.number_of_nodes() - 1)):.4f}")
# Sample some nodes to see their attributes
sample_nodes = list(G.nodes(data=True))[:5]
print("\nSample nodes (first 5):")
for node_id, attrs in sample_nodes:
print(f" {node_id}: {attrs}")
# Sample some edges to see their attributes
sample_edges = list(G.edges(data=True))[:5]
print("\nSample edges (first 5):")
for u, v, attrs in sample_edges:
print(f" {u} -> {v}: {attrs}")
# Get degree statistics
degrees = [d for _, d in G.degree()]
print("\nDegree statistics:")
print(f" Min degree: {min(degrees)}")
print(f" Max degree: {max(degrees)}")
print(f" Average degree: {sum(degrees) / len(degrees):.2f}")
# Check if graph is connected (considering it as undirected for connectivity)
undirected_G = G.to_undirected()
print("\nConnectivity:")
print(f" Number of connected components: {len(list(nx.connected_components(undirected_G)))}")
print(f" Is weakly connected: {nx.is_weakly_connected(G)}")
# Create a visualization of the knowledge graph
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
# Create a smaller subgraph for visualization (reduce data for clarity)
# Get nodes with highest degrees for a meaningful visualization
degrees = dict(G.degree())
top_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)[:20] # Reduced from 30 to 20
visualization_nodes = [node for node, _ in top_nodes]
# Create subgraph with these high-degree nodes
graph = G.subgraph(visualization_nodes)
print(f"Visualization subgraph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
# Create the plot with better styling
fig, ax = plt.subplots(figsize=(18, 14))
fig.patch.set_facecolor("white")
# Use hierarchical layout for better structure
try:
# Try hierarchical layout first
pos = nx.nx_agraph.graphviz_layout(graph, prog="neato")
except (ImportError, nx.NetworkXException):
# Fall back to spring layout with better parameters
pos = nx.spring_layout(graph, k=5, iterations=100, seed=42)
# Calculate node properties
node_degrees = [degrees[node] for node in graph.nodes()]
max_degree = max(node_degrees)
min_degree = min(node_degrees)
# Create better color scheme
colors = plt.cm.plasma(np.linspace(0.2, 0.9, len(node_degrees)))
node_colors = [colors[i] for i in range(len(node_degrees))]
# Draw nodes with improved styling
node_sizes = [max(200, min(2000, deg * 50)) for deg in node_degrees] # Better size scaling
nx.draw_networkx_nodes(graph, pos,
node_color=node_colors,
node_size=node_sizes,
alpha=0.9,
edgecolors="black",
linewidths=1.5,
ax=ax)
# Draw edges with better styling
edge_weights = []
for _, _, _ in graph.edges(data=True):
edge_weights.append(1)
nx.draw_networkx_edges(graph, pos,
alpha=0.4,
edge_color="#666666",
width=1.0,
arrows=True,
arrowsize=15,
arrowstyle="->",
ax=ax)
# Add labels for all nodes with better formatting
labels = {}
for node in graph.nodes():
node_name = graph.nodes[node].get("name", str(node))
# Truncate long names
if len(node_name) > 15:
node_name = node_name[:12] + "..."
labels[node] = node_name
nx.draw_networkx_labels(graph, pos, labels,
font_size=9,
font_weight="bold",
font_color="black", # changed from 'white' to 'black'
ax=ax)
# Improve title and styling
ax.set_title("Temporal Knowledge Graph Visualization\n(Top 20 Most Connected Entities)",
fontsize=18, fontweight="bold", pad=20)
ax.axis("off")
# Add a better colorbar
sm = plt.cm.ScalarMappable(cmap=plt.cm.plasma,
norm=plt.Normalize(vmin=min_degree, vmax=max_degree))
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, shrink=0.6, aspect=30)
cbar.set_label("Node Degree (Number of Connections)", rotation=270, labelpad=25, fontsize=12)
cbar.ax.tick_params(labelsize=10)
# Add margin around the graph
ax.margins(0.1)
plt.tight_layout()
plt.show()
# Print some information about the visualized nodes
print("\nTop entities in visualization:")
for i, (node, degree) in enumerate(top_nodes[:10]):
node_name = G.nodes[node].get("name", "Unknown")
print(f"{i+1:2d}. {node_name} (connections: {degree})")
# Create an improved function for easier graph visualization
def visualise_graph(G, num_nodes=20, figsize=(16, 12)):
"""
Visualize a NetworkX graph with improved styling and reduced data.
Args:
G: NetworkX graph
num_nodes: Number of top nodes to include in visualization (default: 20)
figsize: Figure size tuple
"""
degrees = dict(G.degree())
top_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)[:num_nodes]
visualization_nodes = [node for node, _ in top_nodes]
# Create subgraph
subgraph = G.subgraph(visualization_nodes)
# Create the plot
fig, ax = plt.subplots(figsize=figsize)
fig.patch.set_facecolor("white")
# Layout with better parameters
try:
pos = nx.nx_agraph.graphviz_layout(subgraph, prog="neato")
except (ImportError, nx.NetworkXException):
pos = nx.spring_layout(subgraph, k=4, iterations=100, seed=42)
# Node properties
node_degrees = [degrees[node] for node in subgraph.nodes()]
max_degree = max(node_degrees)
min_degree = min(node_degrees)
# Better color scheme
colors = plt.cm.plasma(np.linspace(0.2, 0.9, len(node_degrees)))
node_colors = list(colors)
# Draw nodes
node_sizes = [max(200, min(2000, deg * 50)) for deg in node_degrees]
nx.draw_networkx_nodes(subgraph, pos,
node_color=node_colors,
node_size=node_sizes,
alpha=0.9,
edgecolors="black",
linewidths=1.5,
ax=ax)
# Draw edges
nx.draw_networkx_edges(subgraph, pos,
alpha=0.4,
edge_color="#666666",
width=1.0,
arrows=True,
arrowsize=15,
ax=ax)
# Labels
labels = {}
for node in subgraph.nodes():
node_name = subgraph.nodes[node].get("name", str(node))
if len(node_name) > 15:
node_name = node_name[:12] + "..."
labels[node] = node_name
nx.draw_networkx_labels(subgraph, pos, labels,
font_size=9,
font_weight="bold",
font_color="black", # changed from 'white' to 'black'
ax=ax)
ax.set_title(f"Temporal Knowledge Graph\n(Top {num_nodes} Most Connected Entities)",
fontsize=16, fontweight="bold", pad=20)
ax.axis("off")
# Colorbar
sm = plt.cm.ScalarMappable(cmap=plt.cm.plasma,
norm=plt.Normalize(vmin=min_degree, vmax=max_degree))
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, shrink=0.6)
cbar.set_label("Connections", rotation=270, labelpad=20)
ax.margins(0.1)
plt.tight_layout()
plt.show()
return subgraph
# Get node information on NVIDIA, filtering for what they have developed
# Find the node key for NVIDIA (case-insensitive match on name)
nvidia_node = None
for node, data in graph.nodes(data=True):
if "nvidia" in str(data.get("name", "")).lower():
nvidia_node = node
break
if nvidia_node is not None:
print(f"Node key for NVIDIA: {nvidia_node}")
print("Node attributes:")
for k, v in graph.nodes[nvidia_node].items():
print(f" {k}: {v}")
# Show all edges where NVIDIA is the subject and the predicate is 'DEVELOPED' or 'LAUNCHED' or similar
print("\nEdges where NVIDIA developed or launched something:")
for _, v, _, d in graph.out_edges(nvidia_node, data=True, keys=True):
pred = d.get("predicate", "").upper()
if pred in {"LAUNCHED"}:#, "LAUNCHED", "PRODUCES", "CREATED", "INTRODUCED"}:
print(f" {nvidia_node} -[{pred}]-> {v} | {d}")
# Optionally, print the statement if available
if "statement" in d:
print(f" Statement: {d['statement']}")
else:
print("NVIDIA node not found in the graph.")
3.3.2 NetworkX versus Neo4j en producción
Para implementar y utilizar eficazmente el grafo de conocimiento, usamos NetworkX para los propósitos de este manual por varias razones.
- Integración con Python: NetworkX se integra perfectamente con Python, lo que facilita la creación rápida de prototipos y el desarrollo iterativo.
- Facilidad de configuración: Requiere una configuración inicial mínima, sin necesidad de una configuración cliente-servidor como en otras alternativas. Esto lo hace ideal para usuarios que desean ejecutar este manual por sí mismos.
- Compatibilidad con bases de datos en memoria: NetworkX puede gestionar eficientemente grafos con menos de aproximadamente 100,000 nodos, lo cual es apropiado para la escala de datos de este manual.
Sin embargo, cabe señalar que NetworkX carece de persistencia de datos incorporada y, por lo tanto, no suele recomendarse para compilaciones de producción.
Para compilaciones de producción, Neo4j surge como una opción más óptima debido a un conjunto más amplio de características centradas en la producción, que incluyen:
- Almacenamiento y procesamiento nativo de grafos: Optimizado para datos de grafos con un manejo eficiente y de alto rendimiento.
- Motor de consulta optimizado: Aprovecha el lenguaje de consulta Cypher, diseñado explícitamente para un recorrido eficiente de grafos.
- Escalabilidad y persistencia: Gestiona eficazmente conjuntos de datos de grafos extensos, asegurando la persistencia, fiabilidad y durabilidad de los datos.
- Herramientas de producción: Ofrece herramientas integradas como Neo4j Bloom para visualización y Neo4j Browser para exploración, mejorando la interacción y el análisis del usuario.
- Control de acceso avanzado: Proporciona opciones de seguridad granulares para controlar el acceso a los datos.
3.4. Evaluación y sugerencias de adiciones de características
El enfoque presentado anteriormente ofrece una implementación fundamental de un Agente Temporal para la construcción de grafos de conocimiento. Sin embargo, no aborda completamente las complejidades ni todos los posibles casos extremos encontrados en aplicaciones del mundo real. A continuación, describimos varias mejoras posibles que podrían usarse para aumentar aún más la robustez y aplicabilidad de esta implementación. En la sección posterior "Del prototipo a la producción", ampliamos estas mejoras sugiriendo consideraciones adicionales esenciales para implementar dichos agentes de manera efectiva en entornos de producción. Se incluyen más detalles sobre la escalabilidad a producción en el Apéndice.
3.4.1. Agente Temporal
Extracción de declaraciones y eventos temporales
Eventos temporales duplicados
En este manual, el Agente Temporal no identifica ni fusiona Eventos Temporales duplicados que surgen de declaraciones que se refieren al mismo evento, especialmente cuando provienen de diferentes fuentes. Estos eventos se guardan por separado en lugar de unificarse en un solo evento consolidado.
Representación estática y dinámica
Existe la oportunidad de enriquecer el conjunto de datos capturando consistentemente representaciones tanto estáticas como dinámicas de los eventos, incluso cuando no se disponga de declaraciones explícitas.
Para eventos dinámicos sin declaraciones estáticas correspondientes, la creación de entradas estáticas explícitas que marquen el inicio (valid_at) y el final (invalid_at) puede mejorar la claridad temporal, particularmente para fines de tareas de recuperación.
Por el contrario, los eventos estáticos que carecen de contrapartes dinámicas pueden tener relaciones dinámicas inferidas, aunque esto requeriría verificaciones cuidadosas para una posible invalidación dentro de cohortes de declaraciones.
Extracción de fechas
La implementación en este manual no registra explícitamente las suposiciones hechas durante la desambiguación de fechas.
En ausencia de una fecha de publicación explícita, la fecha actual se utiliza implícitamente como referencia. Para algunos flujos de trabajo, esta suposición puede tener que cambiarse para satisfacer las necesidades de los usuarios finales.
Las fechas abstractas (por ejemplo, "hasta el próximo año") se resuelven en fechas explícitas, sin embargo, la vaguedad no se representa en la estructura de datos almacenada. La inclusión de metadatos más granulares puede capturar rangos de fechas más abstractos:
temporal_event = {
"summary": "The event ran from April to September",
"label": "dynamic",
"valid_at": {
"date": "2025-04-01",
"literal": False,
"abstract_date": "2025-04"
},
"invalid_at": {
"date": "2025-09-30",
"literal": False,
"abstract_date": "2025-09"
}
}
Esta estructura permite la representación explícita de interpretaciones de fechas tanto literales como abstractas.
Extracción de tripletas
Existen varias vías posibles para mejorar la Extracción de Tripletas presentada en este manual. Estas incluyen:
- Utilizar un modelo más grande y optimizar aún más los prompts de extracción.
- Ejecutar el proceso de extracción varias veces y consolidar los resultados, por ejemplo, mediante un mecanismo de agrupación modal para mejorar la precisión y la confianza en una predicción.
- Incorporar herramientas de extracción de entidades (por ejemplo, Spacy) y aprovechar ontologías predefinidas adaptadas a casos de uso específicos para mejorar la coherencia y la fiabilidad.
3.4.2. Agente de invalidación
El Agente de Invalidación presentado no refina los rangos de validez temporal, pero se podría extender su funcionalidad para realizar dicho refinamiento, así como verificaciones de invalidación intra-cohorte para identificar conflictos temporales entre las declaraciones entrantes.
También existen varias oportunidades para mejorar la eficiencia.
- La transición de comparaciones individuales (1:1) a verificaciones de invalidación omnidireccionales (1:muchos) reduciría el número de llamadas a LLM requeridas.
- La aplicación de técnicas de análisis de redes para agrupar declaraciones relacionadas podría permitir el procesamiento por lotes de las verificaciones de invalidación. Los clústeres pueden derivarse de varias propiedades, incluida la similitud semántica, la proximidad temporal o técnicas más avanzadas. Esto reduciría significativamente los cuellos de botella que surgen del procesamiento secuencial, lo cual es particularmente importante al ingerir grandes volúmenes de datos.
4. Recuperación en varios pasos sobre un grafo de conocimiento
Los sistemas de recuperación simples a menudo pueden manejar consultas directas de "búsqueda" con una sola búsqueda en un almacén de vectores o índice de documentos. En la práctica, sin embargo, los agentes implementados en entornos del mundo real con frecuencia necesitan más. Las preguntas de los usuarios a menudo requieren que los LLM sinteticen información de múltiples partes de una base de conocimiento o de varios puntos finales.
Los grafos de conocimiento temporales introducidos anteriormente proporcionan una base natural para esto, codificando explícitamente entidades (nodos), relaciones (aristas) y su evolución a lo largo del tiempo.
La recuperación en varios pasos nos permite aprovechar al máximo las capacidades de estos grafos. Implica recorrer iterativamente el grafo a través de una serie de consultas dirigidas, lo que permite al agente recopilar todo el contexto necesario antes de formar una respuesta.
Podemos ver el poder de la recuperación en varios pasos a continuación:

En este caso, la consulta inicial al grafo de conocimiento no arrojó información sobre las actividades de I+D de algunos competidores. En lugar de fallar silenciosamente, el sistema recurrió a una fuente alternativa —el contenido de la estrategia— y localizó con éxito la información faltante. Este enfoque de varios pasos le permitió navegar por datos dispersos y entregar una respuesta completa al usuario.
4.1. Construyendo nuestro Agente de Recuperación
A grandes rasgos, construiremos la siguiente estructura:
-
Pregunta del usuario → Planificador → Orquestador
Un planificador que utiliza GPT 4.1 descompondrá la pregunta del usuario en una pequeña secuencia de operaciones de grafo propuestas. Esto luego se pasa al orquestador para su ejecución.
-
Llamadas a herramientas para recuperar información del Grafo de Conocimiento Temporal
Considerando la consulta del usuario y el plan, el Orquestador (o4-mini) realiza una serie de llamadas iniciales a herramientas para recuperar información del grafo de conocimiento.
-
Bucle hasta terminar → Generar respuesta
Las respuestas a las llamadas a herramientas se retroalimentan al Orquestador, que luego puede decidir si realizar más consultas al grafo o responder la pregunta del usuario.

4.1.1. Importaciones
%pip install --upgrade openai
4.1.2. (Re)Inicializar cliente de OpenAI
from openai import AsyncOpenAI
client = AsyncOpenAI()
4.1.3. (Re)Cargar nuestro Grafo de Conocimiento Temporal
from cb_functions import build_graph, load_db_from_hf
conn = load_db_from_hf()
G = build_graph(conn)
print(G.number_of_nodes(), "nodes,", G.number_of_edges(), "edges")
4.1.4. Planificador
Los pasos de planificación se incorporan en muchas aplicaciones modernas de LLM.
La inclusión explícita de un paso de planificación mejora el rendimiento general al hacer que el sistema considere el alcance completo del problema antes de actuar.
En esta implementación, el plan permanece estático. Sin embargo, en pipelines de agentes de horizonte más largo, es común incluir mecanismos para replanificar o actualizar el plan a medida que el sistema avanza.
En términos generales, los planificadores adoptan dos formas:
-
Orientado a tareas (utilizado en este manual)
El planificador describe las subtareas concretas que deben ejecutar los bloques de agentes posteriores. Las tareas se expresan en un sentido orientado a la acción, como "1. Extraer información sobre las actividades de I+D de la Compañía IJK entre 2018 y 2020". Estos planificadores suelen preferirse cuando el objetivo es mayormente determinista y el riesgo principal es omitir o duplicar trabajo.
Ejemplos de tareas donde este enfoque es útil:
- Derecho: "Extraer y tabular los períodos de aviso de terminación de cada acuerdo maestro de servicios ejecutado en el AF24"
- Finanzas: "Obtener todos los 10-K presentados por los bancos del S&P 500 para el AF24, extraer el capital de nivel 1 y los ratios de cobertura de liquidez, y generar una tabla clasificada de instituciones por adecuación de capital"
- Automotriz: "Compilar el número de reclamaciones de garantía por componente para vehículos Modelo XYZ vendidos en Europa desde que entró en vigor la nueva regulación de emisiones"
- Manufactura: "Analizar los registros de tiempo de inactividad de cada máquina CNC para el primer trimestre de 2025, clasificar los códigos de causa raíz y generar un diagrama de Pareto de los cinco principales factores de falla"
-
Orientado a hipótesis
El plan se enmarca como un conjunto de hipótesis que el sistema puede confirmar, rechazar o refinar en respuesta a la pregunta del usuario. Cada paso representa una afirmación comprobable, opcionalmente emparejada con acciones sugeridas. Este enfoque sobresale en tareas de investigación abiertas donde la nueva información puede remodelar significativamente el espacio de la solución.
Ejemplos de tareas donde este enfoque es útil:
- Derecho: "¿La evidencia proporcionada satisface los cuatro puntos de la doctrina del uso justo? Evaluar cada punto contra la jurisprudencia relevante"
- Farmacéutica: "¿Qué métodos emergentes de entrega de ARNm podrían usarse para atacar el gen IRS1 para tratar la obesidad?"
- Finanzas: "¿El Banco Alpha enfrenta un riesgo de liquidez? Comparar su tendencia de LCR, los costos de endeudamiento interbancario y las salidas de depósitos, y cualquier otra cosa interesante que encuentres"
Indicando a nuestro planificador
Definiremos dos prompts (uno system y otro user) para el planificador inicial.
La característica más notable de nuestro prompt de sistema a continuación es el uso de prompting 'basado en persona'. Le indicamos al LLM dándole la persona de un experto interno de la empresa. Esto ayuda a enmarcar el tono de la respuesta del modelo al comportamiento que queremos: una lista de tareas directa y orientada a la acción que sea adecuada para la industria financiera.
Esto se extiende luego en el prompt del usuario, donde anteponemos el user_question con información sobre esta situación específica y cómo el planificador debe manejarla.
En entornos de producción, puedes potenciar esta plantilla enriqueciendo dinámicamente el prompt antes de cada llamada. Puedes inyectar información sobre el perfil del usuario —sector, rol, estilo de escritura preferido, contexto de conversación anterior— para que el planificador adapte sus acciones a su entorno. También puedes realizar un rápido bucle de "construcción de preguntas": hacer que el asistente proponga preguntas aclaratorias, recopilar las respuestas y fusionarlas de nuevo en el prompt para que el planificador comience con una solicitud bien definida y rica en información en lugar de una vaga.
Otro flujo que puede funcionar bien es permitir a los usuarios ver el plan y, opcionalmente, editarlo antes de que se ejecute. Esto es particularmente efectivo cuando tu sistema de IA actúa más como un asistente. Dar a expertos en el dominio, como abogados o investigadores farmacéuticos, la flexibilidad de dirigir e incorporar sus ideas y direcciones de investigación más profundamente en el sistema a menudo tiene el doble beneficio de mejorar tanto el rendimiento del sistema como la satisfacción del usuario final.
async def initial_planner(user_question: str) -> str:
"""Return an initial plan for answering the user's question."""
initial_planner_system_prompt = (
"You work for the leading financial firm, ABC Incorporated, one of the largest financial firms in the world. "
"Due to your long and esteemed tenure at the firm, various equity research teams will often come to you "
"for guidance on research tasks they are performing. Your expertise is particularly strong in the area of "
"ABC Incorporated's proprietary knowledge base of earnings call transcripts. This contains details that have been "
"extracted from the earnings call transcripts of various companies with labelling for when these statements are, or "
"were, valid. You are an expert at providing instructions to teams on how to use this knowledge graph to answer "
"their research queries. \n"
"The teams will have access to the following tools to help them retrieve information from the knowledge graph: \n"
"1. `factual_qa`: Queries the knowledge graph for time-bounded factual relationships involving a given entity and predicate. \n"
"2. `trend_analysis`: Wraps the factual_qa tool with a specialised agent to perform in-depth trend analysis \n"
"It shoudld also be noted that the trend_analysis tool can accept multiple predicate arguments as a list. \n "
"You may recommend that multiple calls are made to the tools with different e.g., predicates if this is useful. \n "
"Your recommendation should explain to the team how to retrieve the information from the database through these "
"tools only. "
)
initial_planner_user_prompt = (
"Your top equity research team has came to you with a research question they are trying to find the answer to. "
"You should use your deep financial expertise to succinctly detail a step-by-step plan for retrieving "
"this information from the the company's knowledge base of earnings call transcripts extracts. "
"You should produce a concise set of individual research tasks required to thoroughly address the team's query. "
"These tasks should cover all of the key points of the team's research task without overcomplicating it. \n\n"
"The question the team has is: \n\n"
f"{user_question} \n\n"
"Return your answer under a heading 'Research tasks' with no filler language, only the plan."
)
input_messages = [
{"role":"system", "content": initial_planner_system_prompt},
{"role":"user", "content": initial_planner_user_prompt}
]
initial_plan = await client.responses.create(
model="gpt-4.1",
input=input_messages
)
return initial_plan.output_text
plan = await initial_planner("How can we find out how AMD's research priorties have changed in the last 4 years?")
print(plan)
4.1.5. Llamada a funciones
Las llamadas a funciones de OpenAI (también conocidas como herramientas) permiten a los modelos realizar acciones externas específicas llamando a funciones predefinidas. Algunas de las herramientas proporcionadas en la plataforma OpenAI incluyen:
- Intérprete de código: Ejecuta código para análisis de datos, matemáticas, trazado y manipulación de archivos.
- Búsqueda web: Incluye datos de internet en la generación de respuestas del modelo.
- Búsqueda de archivos: Busca el contenido de archivos cargados para obtener contexto.
- Generación de imágenes: Genera o edita imágenes usando GPT image.
- Servidores MCP remotos: Da al modelo acceso a nuevas capacidades a través de servidores Model Context Protocol (MCP).
Otros manuales cubren cómo construir herramientas para usar con LLM. En este ejemplo, desarrollaremos varias herramientas diseñadas para explorar eficientemente el grafo de conocimiento temporal y ayudar a responder la pregunta del usuario.
Existen varias escuelas de pensamiento sobre el diseño de herramientas, y la mejor opción depende de la aplicación en cuestión.

Herramientas fijas
En este contexto, las herramientas 'fijas' se refieren a aquellas con una funcionalidad rígida y bien definida. Típicamente, estas herramientas aceptan un número limitado de argumentos específicos y realizan tareas claramente delineadas. Por ejemplo, una herramienta fija podría ejecutar una consulta simple como "Obtener el clima de hoy para la ubicación del usuario". Debido a su naturaleza estructurada, estas herramientas sobresalen en la realización de búsquedas consistentes o en la monitorización de valores dentro de entornos estructurados como sistemas ERP, marcos regulatorios o paneles de control. Sin embargo, su rigidez limita la flexibilidad, lo que lleva a los usuarios a menudo a reemplazarlas con pipelines de datos más dinámicos y tradicionales, particularmente para la transmisión continua de datos.
Ejemplos de herramientas fijas en varias industrias incluyen:
- Finanzas: "¿Cuál es el tipo de cambio actual de USD a EUR?"
- Farmacéutica: "Recuperar los efectos adversos conocidos para el medicamento ABC."
- Manufactura: "¿Cuál fue la tasa de defectos para el lote #42?"
Forma libre
Las herramientas de forma libre representan el extremo más flexible del espectro de herramientas. Estas herramientas son capaces de ejecutar tareas complejas y abiertas con mínimas restricciones en la estructura de entrada. Un ejemplo común es un intérprete de código, capaz de manejar diversas tareas analíticas. Aunque su flexibilidad ofrece ventajas sustanciales, también pueden introducir imprevisibilidad y ser más difíciles de optimizar para una fiabilidad consistente.
En aplicaciones industriales, las herramientas de forma libre pueden verse así:
- Finanzas: "Realiza un backtest de esta estrategia de trading de momentum utilizando datos de precios de ETF de los últimos 10 años y grafica la distribución del ratio de Sharpe."
- Automotriz: "Dado este registro de telemetría sin procesar, identifica patrones que indiquen una falla temprana de los frenos y simula resultados bajo diversas condiciones de terreno."
- Farmacéutica: "Crea un pipeline que filtre la regulación al alza de genes estadísticamente significativa de este conjunto de datos, luego ejecuta un análisis de enriquecimiento de conjuntos de genes y genera una figura lista para publicación."
Herramientas semiestructuradas (utilizadas en este manual)
Los flujos de trabajo de agentes modernos con frecuencia requieren herramientas que equilibren eficazmente la estructura y la flexibilidad. Las herramientas semiestructuradas están diseñadas específicamente para gestionar este punto intermedio. Aceptan entradas en formatos moderadamente complejos —como fragmentos de texto, argumentos tipo JSON o pequeños fragmentos de código— y a menudo incorporan capacidades básicas de razonamiento, recuperación o toma de decisiones. Estas herramientas son ideales cuando las tareas están bien definidas pero no son completamente uniformes, como cuando se conoce el conjunto de datos o servicio requerido, pero la consulta o la salida esperada varían.
Dos paradigmas comunes de herramientas semiestructuradas son:
- Capacidades extendidas: Herramientas que funcionan como agentes especializados, incorporando lógica interna y rutinas de análisis.
- Interfaces de argumentos flexibles: Herramientas que permiten al LLM pasar argumentos expresivos pero estructurados, como consultas detalladas, filtros o funciones incrustadas.
Las herramientas semiestructuradas son particularmente valiosas cuando:
- Se delegan tareas específicas pero no triviales (como búsquedas, transformaciones o resúmenes) a herramientas especializadas.
- Los datos de origen o las API son conocidos, pero los resultados devueltos pueden ser impredecibles.
En entornos de producción, estas herramientas suelen ser preferibles a las herramientas de forma libre, como los intérpretes de código, debido a su mayor fiabilidad y rendimiento. Por ejemplo, ejecutar consultas complejas y de varios pasos en grandes grafos de conocimiento de Neo4j es más fiable y eficiente utilizando consultas Cypher optimizadas y con plantillas dentro de herramientas semiestructuradas en lugar de generar cada consulta desde cero.
Las aplicaciones industriales de las herramientas semiestructuradas incluyen:
- Finanzas: "Extraer todos los factores de riesgo prospectivos de los informes de la empresa para el segundo trimestre de 2023."
- Automotriz: "Identificar fallas eléctricas recurrentes de los registros de mantenimiento en modelos de vehículos eléctricos lanzados después de 2020."
- Farmacéutica: "Localizar datos ómicos que respalden la hipótesis de que un tratamiento específico de ARNm regula eficazmente al alza el gen IRS1."
Creando herramientas para que nuestro recuperador las use
Preguntas y respuestas factuales
La herramienta factual_qa proporciona una forma eficiente para que nuestro agente recupere información de nuestro grafo de conocimiento temporal relacionada con una empresa, tema y rango de fechas específicos. Esto ayudará al agente a responder preguntas sobre los datos, como "¿Cuáles fueron las ganancias de AMD en el tercer trimestre de 2017?"
Esta herramienta se encuentra en algún punto intermedio entre las herramientas fijas y semiestructuradas que presentamos anteriormente. Generalmente es una herramienta bastante rígida en el sentido de que restringe al agente a un pequeño número de parámetros. Sin embargo, los grados de libertad en la entrada son grandes y la herramienta sigue siendo flexible en la información que puede recuperar del grafo de conocimiento. Esto ayuda a evitar la necesidad de que el agente principal escriba nuevas consultas para networkx desde cero en cada consulta, mejorando la precisión y la latencia.
La herramienta tiene los siguientes argumentos:
entity: Esta es la entidad (u objeto con respecto a la ontología de tripletas) para la que la herramienta debe recuperar información.start_date_range: Este es el límite inferior del rango de fechas sobre el que la herramienta debe recuperar.end_date_range: Este es el límite superior del rango de fechas sobre el que la herramienta debe recuperar.predicate: Este es el nombre del predicado que la herramienta conectará alentitypara realizar una recuperación.
Comenzamos cargando las definiciones de predicados. Las usaremos para mejorar la tolerancia a errores en la herramienta, utilizando un GPT-4.1-nano para normalizar el predicado pasado en el argumento a un nombre de predicado válido.
# Redefine the predicate definitions as we will need them here
PREDICATE_DEFINITIONS = {
"IS_A": "Denotes a class-or-type relationship between two entities (e.g., 'Model Y IS_A electric-SUV'). Includes 'is' and 'was'.",
"HAS_A": "Denotes a part-whole relationship between two entities (e.g., 'Model Y HAS_A electric-engine'). Includes 'has' and 'had'.",
"LOCATED_IN": "Specifies geographic or organisational containment or proximity (e.g., headquarters LOCATED_IN Berlin).",
"HOLDS_ROLE": "Connects a person to a formal office or title within an organisation (CEO, Chair, Director, etc.).",
"PRODUCES": "Indicates that an entity manufactures, builds, or creates a product, service, or infrastructure (includes scale-ups and component inclusion).",
"SELLS": "Marks a commercial seller-to-customer relationship for a product or service (markets, distributes, sells).",
"LAUNCHED": "Captures the official first release, shipment, or public start of a product, service, or initiative.",
"DEVELOPED": "Shows design, R&D, or innovation origin of a technology, product, or capability. Includes 'researched' or 'created'.",
"ADOPTED_BY": "Indicates that a technology or product has been taken up, deployed, or implemented by another entity.",
"INVESTS_IN": "Represents the flow of capital or resources from one entity into another (equity, funding rounds, strategic investment).",
"COLLABORATES_WITH": "Generic partnership, alliance, joint venture, or licensing relationship between entities.",
"SUPPLIES": "Captures vendor–client supply-chain links or dependencies (provides to, sources from).",
"HAS_REVENUE": "Associates an entity with a revenue amount or metric—actual, reported, or projected.",
"INCREASED": "Expresses an upward change in a metric (revenue, market share, output) relative to a prior period or baseline.",
"DECREASED": "Expresses a downward change in a metric relative to a prior period or baseline.",
"RESULTED_IN": "Captures a causal relationship where one event or factor leads to a specific outcome (positive or negative).",
"TARGETS": "Denotes a strategic objective, market segment, or customer group that an entity seeks to reach.",
"PART_OF": "Expresses hierarchical membership or subset relationships (division, subsidiary, managed by, belongs to).",
"DISCONTINUED": "Indicates official end-of-life, shutdown, or termination of a product, service, or relationship.",
"SECURED": "Marks the successful acquisition of funding, contracts, assets, or rights by an entity.",
}
Definimos varias funciones auxiliares para la herramienta de preguntas y respuestas factuales.
Primero está _as_datetime. Esta herramienta se utiliza para forzar los argumentos que definen el rango de fechas al formato datetime correcto.
A continuación, introducimos dos nuevos modelos de datos: PredicateMatching y PredicateMatchValidation. PredicateMatching define el formato de salida para la llamada a GPT-4.1-nano que coincide con el predicado en los argumentos de la función con nombres de predicados válidos. PredicateMatchValidation luego realiza un paso de validación secundario para afirmar que esta salida de GPT-4.1-nano es un nombre de predicado válido, aprovechando un validador de campo Pydantic. Este proceso ayuda a garantizar que la herramienta funcione sin problemas y ayuda a eliminar algunos de los casos extremos raros que conducirían a una consulta de grafo fallida.
# Helper functions and models
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError, field_validator
def _as_datetime(ts) -> datetime | None:
"""Helper function to coerce possible timestamp formats to `datetime`.""" # noqa: D401
if ts is None:
return None
if isinstance(ts, datetime):
return ts
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(ts, fmt)
except ValueError:
continue
return None
class PredicateMatching(BaseModel):
"""Class for structured outputs from model to coerce input to correct predicate format."""
reasoning: str = Field(description="Use this space to reason about the correct predicate to match.")
predicate_match: str = Field(description="The predicate that aligns with the dictionary.")
class PredicateMatchValidation(BaseModel):
"""Class for validating the outputs from the model that tries to coerce predicate argument to a real predicate."""
predicate: str
@field_validator("predicate")
@classmethod
def predicate_in_definitions(cls, v):
"""Return an error string if the predicate is not in PREDICATE_DEFINITIONS."""
if v not in PREDICATE_DEFINITIONS:
return f"Error: '{v}' is not a valid predicate. Must be one of: {list(PREDICATE_DEFINITIONS.keys())}"
return v
Nuestra herramienta de preguntas y respuestas factuales se puede descomponer en cuatro pasos.
-
Coerción de predicados
Si el predicado proporcionado no se encuentra en el diccionario
PREDICATE_DEFINITIONS, este paso utiliza GPT-4.1-nano para convertirlo en un predicado válido. -
Localización de entidades
Realiza una coincidencia difusa para identificar los nodos de entidad correspondientes dentro del grafo de networkx.
-
Recopilación de aristas
Recupera tanto las aristas entrantes como las salientes asociadas con los nodos de entidad identificados.
-
Formato de respuesta
Estructura la información recopilada en una respuesta bien formateada que sea fácil de consumir para el orquestador.
async def factual_qa(
entity: str,
start_date_range: datetime,
end_date_range: datetime,
predicate: str
) -> str:
"""
Query the knowledge-graph for relationships attached to *entity* that match
*predicate* and fall within the requested time-window.
The response is rendered as:
Subject – PREDICATE – Object [Valid-From]
Statement: "..."
Type: FACT • Value: 42
If no matches are found (or on error) a human-readable explanation is returned.
"""
# Checks that the date range passed is logical
if start_date_range > end_date_range:
return (
"You used the `factual_qa` tool incorrectly last time. You provided a "
"`start_date_range` that was more recent than the `end_date_range`. "
"`end_date_range` must be ≥ `start_date_range`."
)
# ---- (1) predicate coercion / validation -----------------------
if predicate not in PREDICATE_DEFINITIONS:
try:
predicate_definitions_str = "\n".join(
f"- {k}: {v}" for k, v in PREDICATE_DEFINITIONS.items()
)
coercion_prompt = (
"You are a helpful assistant that matches predicates to a dictionary of "
"predicate definitions. Return the best-matching predicate **and** your reasoning.\n\n"
f"Dictionary:\n{predicate_definitions_str}\n\n"
f"Predicate to match: {predicate}"
)
completion = await client.beta.chat.completions.parse(
model="gpt-4.1-nano",
messages=[{"role": "user", "content": coercion_prompt}],
response_format=PredicateMatching,
)
coerced_predicate = completion.choices[0].message.parsed.predicate_match
# Validate against the enum / model we expect
_ = PredicateMatchValidation(predicate=coerced_predicate)
predicate = coerced_predicate
except ValidationError:
return (
"You provided an invalid predicate. "
f"Valid predicates are: {list(PREDICATE_DEFINITIONS.keys())}"
)
except Exception:
# Coercion failed – fall back to original predicate
pass
predicate_upper = predicate.upper()
entity_lower = entity.lower()
# ---- (2) locate the entity node by fuzzy match -----------------
try:
target_node = None
for node, data in G.nodes(data=True):
node_name = data.get("name", str(node))
if entity_lower in node_name.lower() or node_name.lower() in entity_lower:
target_node = node
break
if target_node is None:
return f"Entity '{entity}' not found in the knowledge graph."
except Exception as e:
return f"Error locating entity '{entity}': {str(e)}"
# ---- (3) collect matching edges (outgoing + incoming) ----------
matching_edges = []
def _edge_ok(edge_data):
"""Return True if edge is temporally valid in the requested window."""
valid_at = _as_datetime(edge_data.get("valid_at"))
invalid_at = _as_datetime(edge_data.get("invalid_at"))
if valid_at and end_date_range < valid_at:
return False
if invalid_at and start_date_range >= invalid_at:
return False
return True
# Outgoing
try:
for _, tgt, _, ed in G.out_edges(target_node, data=True, keys=True):
pred = ed.get("predicate", "").upper()
if predicate_upper in pred and _edge_ok(ed):
matching_edges.append(
{
"subject": G.nodes[target_node].get("name", str(target_node)),
"predicate": pred,
"object": G.nodes[tgt].get("name", str(tgt)),
**ed,
}
)
except Exception:
pass
# Incoming
try:
for src, _, _, ed in G.in_edges(target_node, data=True, keys=True):
pred = ed.get("predicate", "").upper()
if predicate_upper in pred and _edge_ok(ed):
matching_edges.append(
{
"subject": G.nodes[src].get("name", str(src)),
"predicate": pred,
"object": G.nodes[target_node].get("name", str(target_node)),
**ed,
}
)
except Exception:
pass
# ---- (4) format the response -----------------------------------
if not matching_edges:
s = start_date_range.strftime("%Y-%m-%d")
e = end_date_range.strftime("%Y-%m-%d")
return (
f"No data found for '{entity}' with predicate '{predicate}' "
f"in the specified date range ({s} to {e})."
)
lines = [
f"Found {len(matching_edges)} relationship"
f"{'s' if len(matching_edges) != 1 else ''} for "
f"'{entity}' with predicate '{predicate}':",
""
]
for idx, edge in enumerate(matching_edges, 1):
value = edge.get("value")
statement = edge.get("statement")
statement_tp = edge.get("statement_type")
valid_from = edge.get("valid_at")
# First line: Subject – PREDICATE – Object
triplet = f"{edge['subject']} – {edge['predicate']} – {edge['object']}"
if valid_from:
triplet += f" [Valid-from: {valid_from}]"
if value is not None:
triplet += f" (Value: {value})"
lines.append(f"{idx}. {triplet}")
# Second line: Statement (truncated to 200 chars) + Type
if statement:
snippet = statement if len(statement) <= 200 else statement[:197] + "…"
lines.append(f' Statement: "{snippet}"')
if statement_tp:
lines.append(f" Type: {statement_tp}")
lines.append("") # spacer
return "\n".join(lines)
result = await factual_qa(
entity="Amd",
start_date_range=datetime(2016, 1, 1),
end_date_range=datetime(2020, 1, 1),
predicate="launched"
)
print(result)
factual_qa_schema = {
"type": "function",
"name": "factual_qa",
"description": "Queries the knowledge graph for time-bounded factual relationships involving a given entity and predicate.",
"parameters": {
"type": "object",
"properties": {
"entity": {
"type": "string",
"description": "The name of the entity (e.g., company or organization) whose relationships should be retrieved."
},
"start_date_range": {
"type": "string",
"format": "date",
"description": "The start (inclusive) of the date range to filter factual relationships."
},
"end_date_range": {
"type": "string",
"format": "date",
"description": "The end (inclusive) of the date range to filter factual relationships."
},
"predicate": {
"type": "string",
"description": "The type of relationship or topic to match against the knowledge graph (e.g., 'invested_in', 'founded')."
}
},
"required": [
"entity",
"start_date_range",
"end_date_range",
"predicate"
],
"additionalProperties": False
}
}
Análisis de tendencias
La herramienta trend_analysis está diseñada para comparar cómo métricas o señales específicas evolucionan con el tiempo, a menudo en varias empresas y/o temas. Expone una interfaz estructurada que permite al agente especificar la ventana de tiempo, el conjunto de sujetos y la métrica objetivo, luego delega la lógica de comparación a un agente especializado para manejar este análisis. En este caso, utilizamos o4-mini con un alto esfuerzo de razonamiento, ya que esta es una tarea de análisis 'más difícil'.
Esto nos permite construir un pipeline altamente enfocado y optimizado para lidiar con tareas de estilo de comparación. Si bien esto podría integrarse en el propio orquestador principal, a menudo es más manejable dividirlo en herramientas especializadas para que puedan intercambiarse o actualizarse más fácilmente más adelante sin mucha preocupación por el impacto en el sistema en general.
import asyncio
from datetime import datetime
async def trend_analysis(
question: str,
companies: list[str],
start_date_range: datetime,
end_date_range: datetime,
topic_filter: list[str],
) -> str:
"""
Aggregate knowledge-graph facts for multiple companies and topics.
For every (company, topic) pair, this calls `factual_qa` with the same
date window and returns one concatenated, human-readable string.
Sections are separated by blank lines and prefixed with:
=== <Company> · <Topic> ===
If `factual_qa` raises an exception, an ⚠️ line with the error message
is included in place of that section.
"""
# -------- helper ------------------------------------------------------
async def _fetch(company: str, predicate: str) -> str:
return await factual_qa(
entity=company,
start_date_range=start_date_range,
end_date_range=end_date_range,
predicate=predicate,
)
# -------- schedule every call (concurrently) --------------------------
pairs = [(c, p) for c in companies for p in topic_filter]
tasks = [asyncio.create_task(_fetch(c, p)) for c, p in pairs]
# -------- gather results ---------------------------------------------
results = await asyncio.gather(*tasks, return_exceptions=True)
# -------- assemble final string --------------------------------------
sections: list[str] = []
for (company, predicate), res in zip(pairs, results, strict=True):
header = f"=== {company} · {predicate} ==="
if isinstance(res, Exception):
sections.append(f"{header}\n⚠️ {type(res).__name__}: {res}")
else:
sections.append(f"{header}\n{res}")
joined = "\n\n".join(sections)
analysis_user_prompt = (
"You are a helpful assistant"
"You specialise in providing in-depth analyses of financial data. "
"You are provided with a detailed dump of data from a knowledge graph that contains data that has been "
"extracted from companies' earnings call transcripts. \n"
"Please summarise the trends from this, comparing how data has evolved over time in as much detail as possible. "
"Your answer should only contain information that is derived from the data provided, do not lean on your internal "
"knowledge. The knowledge graph contains data in the range 2016-2020. "
"The data provided is: \n"
f"{joined}\n\n"
f"The user question you are summarizing for is: {question}"
)
analysis = await client.responses.create(
model="o4-mini",
input=analysis_user_prompt,
reasoning={
"effort": "high",
"summary": "auto"
}
)
return analysis.output_text
result = await trend_analysis(
question="How have AMD's research priorties changed over time?",
companies=["AMD"],
start_date_range=datetime(2016, 1, 1),
end_date_range=datetime(2020, 1, 1),
topic_filter=["launched", "researched", "developed"]
)
print(result)
trend_analysis_schema = {
"type": "function",
"name": "trend_analysis",
"description": "Aggregates and compares knowledge-graph facts for multiple companies and topics over a time range, returning a trend summary.",
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "A free-text question that guides the trend analysis (e.g., 'How did hiring trends differ between companies?')."
},
"companies": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of companies to compare (e.g., ['Apple', 'Microsoft'])."
},
"start_date_range": {
"type": "string",
"format": "date",
"description": "The start (inclusive) of the date range to filter knowledge-graph facts."
},
"end_date_range": {
"type": "string",
"format": "date",
"description": "The end (inclusive) of the date range to filter knowledge-graph facts."
},
"topic_filter": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of predicates (topics) to query for each company (e.g., ['hired_executive', 'launched_product'])."
}
},
"required": [
"question",
"companies",
"start_date_range",
"end_date_range",
"topic_filter"
],
"additionalProperties": False
}
}
tools = [
factual_qa_schema,
trend_analysis_schema
]