Lección 235 · 30 min · Gratis

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 evento
  • chunk_id: Apunta al Chunk específico asociado con el evento
  • statement: El RawStatement específico extraído del Chunk que detalla una relación o evento
  • embedding: Una representación del statement utilizada por el InvalidationAgent para medir la similitud del evento
  • triplets: Identificadores únicos para los Triplets individuales extraídos del Statement
  • valid_at: Marca de tiempo que indica cuándo el evento se vuelve válido
  • invalid_at: Marca de tiempo que indica cuándo el evento se vuelve inválido
  • temporal_type: Describe las características temporales del RawStatement
  • statement_type: Categoriza la declaración según el RawStatement original
  • created_at: Fecha en que el evento fue creado por primera vez.
  • expired_at: Fecha en que el evento fue marcado como inválido (establecido en created_at si invalid_at ya está establecido al construir el TemporalEvent)
  • invalidated_by: ID del TemporalEvent responsable 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:

  1. Extrae un RawStatement de cada Chunk.
  2. De cada RawStatement, identifica el TemporalValidityRange junto con listas de objetos Triplet y Entity relacionados.
  3. Finalmente, agrupa toda esta información de manera ordenada en un TemporalEvent para cada RawStatement.

Esto es lo que obtendrás:

  • transcript: La transcripción que se está analizando actualmente.
  • all_events: Una lista completa de todos los objetos TemporalEvent generados.
  • all_triplets: Una colección completa de objetos Triplet extraídos de todos los eventos.
  • all_entities: Una lista detallada de todos los objetos Entity extraídos de los eventos, que se refinarán en pasos posteriores.

El siguiente diagrama visualiza esta parte de nuestro pipeline:

Temporal Agent Class

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 EntityResolution para 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 producir AMDI, que coincide estrechamente con el acrónimo AMD. 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.description junto con Entity.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:

  1. Entrantes vs. Existentes: ¿Los eventos entrantes son invalidados por eventos ya presentes?
  2. 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:

  1. Configuración de la Tarea
    Define dos roles —primary y secondary— para la comparación de eventos. La evaluación verifica si el evento primary es invalidado por el evento secondary.
  2. 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.
  3. Información del Evento
    Ambos eventos (primary y secondary) incluyen detalles de marca de tiempo (valid_at y invalid_at) junto con contexto semántico a través de Statement, 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:

  1. Debe ser de tipo FACT y no Atemporal
  2. Compartir al menos una entidad canónica a nivel de triplete
  3. Pertenecer al mismo grupo de predicado semántico a nivel de triplete (definido a continuación)
  4. Superponerse temporalmente y estar actualmente en curso
  5. Tener una similitud de coseno de declaración por encima del umbral (actualmente establecido en 0.5)
  6. El umbral de similitud (0.5) nos ayuda a filtrar el ruido de manera efectiva seleccionando solo los top_k resultados 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í:

  1. Detección de Rango Temporal

    Comenzamos identificando cuándo ocurren los eventos con get_incoming_temporal_bounds(). Esta función verifica el valid_at del evento y, si es dinámico, su invalid_at. Los eventos atemporales no se incluyen aquí.

  2. 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.
  3. 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).
  4. 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.
  5. 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_at para que coincida con el valid_at del evento secundario.
      • expired_at con la marca de tiempo actual.
      • invalidated_by con el ID del evento secundario.
  6. 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.
  7. 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:

Invalidation Agent

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:

Full 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.

  1. Integración con Python: NetworkX se integra perfectamente con Python, lo que facilita la creación rápida de prototipos y el desarrollo iterativo.
  2. 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.
  3. 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:

Multi Retrieval Agent

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:

  1. 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.

  2. 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.

  3. 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.

Retrieval Agent

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:

  1. 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"
  2. 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.

Spectrum of tools

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á al entity para 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.

  1. 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.

  2. Localización de entidades

    Realiza una coincidencia difusa para identificar los nodos de entidad correspondientes dentro del grafo de networkx.

  3. Recopilación de aristas

    Recupera tanto las aristas entrantes como las salientes asociadas con los nodos de entidad identificados.

  4. 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
]
Lección del curso «OpenAI Cookbook» de OpenAI, publicado con licencia MIT. Traducción y adaptación al español de IA con Clase. IA con Clase no está afiliado a OpenAI. Ver el original · Licencia
Esta lección es gratuita. El resto del curso se abre con la Membresía de IA con Clase, que incluye todos los cursos del catálogo. Ver precios
← AnteriorSiguiente: Grafos de conocimiento con conciencia temporal y recuperación multi-salto (parte 3 de 3) →