Knowledge Graph

Core knowledge-graph operations used for fact extraction, linking, and evolution.

logger = logging.getLogger(__name__) module-attribute

_get_similar_by_embedding(query, candidates, model='nomic-embed-text', provider='ollama', top_k=20)

Pre-filter candidates by embedding cosine similarity.

Returns top-K candidate strings most similar to query. Falls back to returning all candidates if embedding fails.

Source code in npcpy/memory/knowledge_graph.py
def _get_similar_by_embedding(query, candidates, model='nomic-embed-text',
                              provider='ollama', top_k=20):
    """Pre-filter candidates by embedding cosine similarity.

    Returns top-K candidate strings most similar to query.
    Falls back to returning all candidates if embedding fails.
    """
    if not candidates or len(candidates) <= top_k:
        return list(candidates)

    try:
        from npcpy.gen.embeddings import get_embeddings

        query_emb = np.array(get_embeddings([query], model, provider)[0])
        cand_embs = get_embeddings(list(candidates), model, provider)

        similarities = []
        for i, emb in enumerate(cand_embs):
            emb_arr = np.array(emb)
            norm_product = np.linalg.norm(query_emb) * np.linalg.norm(emb_arr)
            if norm_product > 0:
                sim = float(np.dot(query_emb, emb_arr) / norm_product)
            else:
                sim = 0.0
            similarities.append((sim, i))

        similarities.sort(key=lambda x: -x[0])
        return [candidates[idx] for _, idx in similarities[:top_k]]
    except Exception as e:
        logger.warning(f"Embedding pre-filter failed, using all candidates: {e}")
        return list(candidates)

abstract(groups, model, provider, npc=None, context=None, **kwargs)

Create more abstract terms from groups.

Source code in npcpy/llm_funcs.py
def abstract(groups, 
             model, 
             provider, 
             npc=None,
             context: str = None, 
             **kwargs):
    """
    Create more abstract terms from groups.
    """
    sample_groups = random.sample(groups, min(len(groups), max(3, len(groups) // 2)))

    groups_text_for_prompt = "\n".join([f'- "{g["name"]}"' for g in sample_groups])

    prompt = f"""
        Create more abstract categories from this list of groups.

        Groups:
        {groups_text_for_prompt}

        You will create higher-level concepts that interrelate between the given groups. 

        Create abstract categories that encompass multiple related facts, but do not unnecessarily combine facts with conjunctions. For example, do not try to combine "characters", "settings", and "physical reactions" into a
        compound group like "Characters, Setting, and Physical Reactions". This kind of grouping is not productive and only obfuscates true abstractions. 
        For example, a group that might encompass the three aforermentioned names might be "Literary Themes" or "Video Editing Functionis", depending on the context.
        Your aim is to abstract, not to just arbitrarily generate associations. 

        Group names should never be more than two words. They should not contain gerunds. They should never contain conjunctions like "AND" or "OR".
        Generate no more than 5 new concepts and no fewer than 2. 

        Respond with JSON:
        """ + '{"groups": [{"name": "abstract category name"}]}'

    response = get_llm_response(prompt,
                                model=model,
                                provider=provider,
                                format="json",
                                npc=npc,
                                context=context,
                                **kwargs)

    return response["response"].get("groups", [])

consolidate_facts_llm(new_fact, existing_facts, model, provider, npc=None, context=None, **kwargs)

Uses an LLM to decide if a new fact is novel or redundant.

Source code in npcpy/llm_funcs.py
def consolidate_facts_llm(new_fact, 
                          existing_facts, 
                          model, 
                          provider, 
                          npc=None,
                          context: str =None,
                          **kwargs):
    """
    Uses an LLM to decide if a new fact is novel or redundant.
    """
    prompt = f"""
        Analyze the "New Fact" in the context of the "Existing Facts" list.
        Your task is to determine if the new fact provides genuinely new information or if it is essentially a repeat or minor rephrasing of information already present.

        New Fact:
        "{new_fact['statement']}"

        Existing Facts:
        {json.dumps([f['statement'] for f in existing_facts], indent=2)}

        Possible decisions:
        - 'novel': The fact introduces new, distinct information not covered by the existing facts.
        - 'redundant': The fact repeats information already present in the existing facts.

        Respond with a JSON object:
        """ + '{"decision": "novel or redundant", "reason": "A brief explanation for your decision."}'
    response = get_llm_response(prompt,
                                model=model, 
                                provider=provider, 
                                format="json", 
                                npc=npc,
                                context=context,
                                **kwargs)
    return response['response']

find_similar_facts_chroma(collection, query, query_embedding, n_results=5, metadata_filter=None)

Find facts similar to the query using pre-generated embedding.

Source code in npcpy/memory/knowledge_graph.py
def find_similar_facts_chroma(
    collection,
    query: str,
    query_embedding: List[float],
    n_results: int = 5,
    metadata_filter: Optional[Dict] = None,
) -> List[Dict]:
    """Find facts similar to the query using pre-generated embedding."""
    try:
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=n_results,
            where=metadata_filter,
        )

        formatted_results = []
        for i, doc in enumerate(results["documents"][0]):
            formatted_results.append(
                {
                    "fact": doc,
                    "metadata": results["metadatas"][0][i],
                    "id": results["ids"][0][i],
                    "distance": (
                        results["distances"][0][i] if "distances" in results else None
                    ),
                }
            )

        return formatted_results
    except Exception as e:
        logger.warning(f"Error searching in Chroma: {e}")
        return []

generate_groups(facts, model=None, provider=None, npc=None, context=None, **kwargs)

Generate conceptual groups for facts

Source code in npcpy/llm_funcs.py
def generate_groups(facts, 
                    model=None,
                    provider=None,
                    npc=None,
                    context: str =None, 
                    **kwargs):
    """Generate conceptual groups for facts"""

    facts_text = "\n".join([f"- {fact['statement']}" for fact in facts])

    prompt = f"""
    Generate conceptual groups for this group off facts:

    {facts_text}

    Create categories that encompass multiple related facts, but do not unnecessarily combine facts with conjunctions. 

    Your aim is to generalize commonly occurring ideas into groups, not to just arbitrarily generate associations. 
    Focus on the key commonly occurring items and expresions.     

    Group names should never be more than two words. They should not contain gerunds. They should never contain conjunctions like "AND" or "OR".
    Respond with JSON:
    """ + '{"groups": [{"name": "group name"}]}'

    response = get_llm_response(prompt,
                                model=model,
                                provider=provider,
                                format="json",
                                context=context,
                                npc=npc,
                                **kwargs)

    return response["response"].get("groups", [])

get_facts(content_text, model=None, provider=None, npc=None, context=None, attempt_number=1, n_attempts=3, **kwargs)

Extract facts from content text

Source code in npcpy/llm_funcs.py
def get_facts(content_text, 
              model= None,
              provider = None,
              npc=None,
              context : str=None, 
              attempt_number=1,
              n_attempts=3,

              **kwargs):
    """Extract facts from content text"""

    prompt = f"""
    Extract facts from this text. A fact is a specific statement that can be sourced from the text.

    Example: if text says "the moon is the earth's only currently known satellite", extract:
    - "The moon is a satellite of earth" 
    - "The moon is the only current satellite of earth"
    - "There may have been other satellites of earth" (inferred from "only currently known")

        A fact is a piece of information that makes a statement about the world.
        A fact is typically a sentence that is true or false.
        Facts may be simple or complex. They can also be conflicting with each other, usually
        because there is some hidden context that is not mentioned in the text.
        In any case, it is simply your job to extract a list of facts that could pertain to
        an individual's personality.

        For example, if a message says:
            "since I am a doctor I am often trying to think up new ways to help people.
            Can you help me set up a new kind of software to help with that?"
        You might extract the following facts:
            - The individual is a doctor
            - They are helpful

        Another example:
            "I am a software engineer who loves to play video games. I am also a huge fan of the
            Star Wars franchise and I am a member of the 501st Legion."
        You might extract the following facts:
            - The individual is a software engineer
            - The individual loves to play video games
            - The individual is a huge fan of the Star Wars franchise
            - The individual is a member of the 501st Legion

        Another example:
            "The quantum tunneling effect allows particles to pass through barriers
            that classical physics says they shouldn't be able to cross. This has
            huge implications for semiconductor design."
        You might extract these facts:
            - Quantum tunneling enables particles to pass through barriers that are
              impassable according to classical physics
            - The behavior of quantum tunneling has significant implications for
              how semiconductors must be designed

        Another example:
            "People used to think the Earth was flat. Now we know it's spherical,
            though technically it's an oblate spheroid due to its rotation."
        You might extract these facts:
            - People historically believed the Earth was flat
            - It is now known that the Earth is an oblate spheroid
            - The Earth's oblate spheroid shape is caused by its rotation

        Another example:
            "My research on black holes suggests they emit radiation, but my professor
            says this conflicts with Einstein's work. After reading more papers, I
            learned this is actually Hawking radiation and doesn't conflict at all."
        You might extract the following facts:
            - Black holes emit radiation
            - The professor believes this radiation conflicts with Einstein's work
            - The radiation from black holes is called Hawking radiation
            - Hawking radiation does not conflict with Einstein's work

        Another example:
            "During the pandemic, many developers switched to remote work. I found
            that I'm actually more productive at home, though my company initially
            thought productivity would drop. Now they're keeping remote work permanent."
        You might extract the following facts:
            - The pandemic caused many developers to switch to remote work
            - The individual discovered higher productivity when working from home
            - The company predicted productivity would decrease with remote work
            - The company decided to make remote work a permanent option

        Thus, it is your mission to reliably extract lists of facts.

    Here is the text:
    Text: "{content_text}"

    Facts should never be more than one or two sentences, and they should not be overly complex or literal. They must be explicitly
    derived or inferred from the source text. Do not simply repeat the source text verbatim when stating the fact. 

    No two facts should share substantially similar claims. They should be conceptually distinct and pertain to distinct ideas, avoiding lengthy convoluted or compound facts .
    Respond with JSON:
    """ + '{"facts": [{"statement": "fact statement that builds on input text to state a specific claim that can be falsified through reference to the source material", "source_text": "text snippets related to the source text", "type": "explicit or inferred"}]}'

    response = get_llm_response(prompt, 
                                model=model,
                                provider=provider, 
                                npc=npc,
                                format="json", 
                                context=context,
                                **kwargs)

    if len(response.get("response", {}).get("facts", [])) == 0 and attempt_number < n_attempts:
        print(f"  Attempt {attempt_number} to extract facts yielded no results. Retrying...")
        return get_facts(content_text, 
                         model=model, 
                         provider=provider, 
                         npc=npc,
                         context=context,
                         attempt_number=attempt_number+1,
                         n_attempts=n_attempts,
                         **kwargs)

    return response["response"].get("facts", [])

get_llm_response(prompt, model=None, provider=None, images=None, npc=None, team=None, messages=None, api_url=None, api_key=None, context=None, stream=False, attachments=None, include_usage=False, n_samples=1, matrix=None, **kwargs)

Generate a response using the specified provider and model.

Source code in npcpy/llm_funcs.py
def get_llm_response(
    prompt: str,
    model: str = None,
    provider: str = None,
    images: List[str] = None,
    npc: Any = None,
    team: Any = None,
    messages: List[Dict[str, str]] = None,
    api_url: str = None,
    api_key: str = None,
    context=None,
    stream: bool = False,
    attachments: List[str] = None,
    include_usage: bool = False,
    n_samples: int = 1,
    matrix: Optional[Dict[str, List[Any]]] = None,
    **kwargs,
):
    """Generate a response using the specified provider and model."""
    logger.debug(
        f"[get_llm_response] {len(messages) if messages else 0} messages, "
        f"prompt_len={len(prompt) if prompt else 0}, "
        f"context={'yes' if context else 'no'}"
    )

    base_model, base_provider, base_api_url, base_api_key = resolve_model_provider(
        npc=npc, team=team, model=model, provider=provider,
        api_url=api_url, api_key=api_key, images=images, attachments=attachments,
    )
    if api_key is None:
        api_key = base_api_key

    use_matrix = matrix is not None and len(matrix) > 0
    multi_sample = n_samples and n_samples > 1

    if not use_matrix and not multi_sample:
        # Simple single-call path
        run_model, run_provider, run_api_url, run_api_key = resolve_model_provider(
            npc=npc, team=team, model=base_model, provider=base_provider,
            api_url=api_url, api_key=api_key, images=images, attachments=attachments,
        )
        tool_capable = bool(kwargs.get("tools"))
        system_message = get_system_message(npc, team, tool_capable=tool_capable) if npc is not None else "You are a helpful assistant."

        # Build messages
        run_messages = copy.deepcopy(messages) if messages else []
        if not run_messages:
            run_messages = [{"role": "system", "content": system_message}]

        # Build full text from prompt and/or context
        full_text = ""
        if prompt and context:
            full_text = f"{prompt}\n\n\nUser Provided Context: {context}"
        elif prompt:
            full_text = prompt
        elif context:
            full_text = f"User Provided Context: {context}"

        if full_text:
            if run_messages[-1]["role"] == "user" and isinstance(run_messages[-1]["content"], str):
                run_messages[-1]["content"] += "\n" + full_text
            else:
                run_messages.append({"role": "user", "content": full_text})

        if not run_model:
            raise ValueError("No model specified. Please set a model in your NPC configuration or team settings.")
        return get_litellm_response(
            full_text or None,
            messages=run_messages,
            model=run_model,
            provider=run_provider,
            api_url=run_api_url,
            api_key=api_key,
            images=images,
            attachments=attachments,
            stream=stream,
            include_usage=include_usage,
            **kwargs,
        )

    # Matrix / multi-sample path
    combos = []
    if use_matrix:
        keys = list(matrix.keys())
        values = []
        for key in keys:
            val = matrix[key]
            if isinstance(val, (list, tuple, set)):
                values.append(list(val))
            else:
                values.append([val])
        for combo_values in itertools.product(*values):
            combos.append(dict(zip(keys, combo_values)))
    else:
        combos.append({})

    runs = []
    for combo in combos:
        run_npc = combo.get("npc", npc)
        run_team = combo.get("team", team)
        run_context = combo.get("context", context)
        extra_kwargs = dict(kwargs)
        for k, v in combo.items():
            if k not in {"model", "provider", "npc", "context", "team"}:
                extra_kwargs[k] = v

        run_model, run_provider, run_api_url, run_api_key = resolve_model_provider(
            npc=run_npc, team=run_team,
            model=combo.get("model", base_model),
            provider=combo.get("provider", base_provider),
            api_url=api_url, api_key=api_key, images=images, attachments=attachments,
        )

        tool_capable = bool(extra_kwargs.get("tools"))
        system_message = get_system_message(run_npc, run_team, tool_capable=tool_capable) if run_npc is not None else "You are a helpful assistant."

        run_messages = copy.deepcopy(messages) if messages else []
        if not run_messages:
            run_messages = [{"role": "system", "content": system_message}]

        full_text = ""
        if prompt and run_context:
            full_text = f"{prompt}\n\n\nUser Provided Context: {run_context}"
        elif prompt:
            full_text = prompt
        elif run_context:
            full_text = f"User Provided Context: {run_context}"

        if full_text:
            if run_messages[-1]["role"] == "user" and isinstance(run_messages[-1]["content"], str):
                run_messages[-1]["content"] += "\n" + full_text
            else:
                run_messages.append({"role": "user", "content": full_text})

        for sample_idx in range(max(1, n_samples or 1)):
            resp = get_litellm_response(
                full_text or None,
                messages=run_messages,
                model=run_model,
                provider=run_provider,
                api_url=run_api_url,
                api_key=api_key,
                images=images,
                attachments=attachments,
                stream=stream,
                include_usage=include_usage,
                **extra_kwargs,
            )
            runs.append({
                "response": resp.get("response") if isinstance(resp, dict) else resp,
                "raw": resp,
                "combo": combo,
                "sample_index": sample_idx,
            })

    aggregated = {
        "response": runs[0]["response"] if runs else None,
        "runs": runs,
    }
    if runs and isinstance(runs[0]["raw"], dict) and "messages" in runs[0]["raw"]:
        aggregated["messages"] = runs[0]["raw"].get("messages")
    return aggregated

Links any node (fact or concept) to ALL relevant concepts in the entire ontology.

Source code in npcpy/llm_funcs.py
def get_related_concepts_multi(node_name: str, 
                               node_type: str, 
                               all_concept_names, 
                               model: str = None,
                               provider: str = None,
                               npc=None,
                               context : str = None, 
                               **kwargs):
    """Links any node (fact or concept) to ALL relevant concepts in the entire ontology."""
    json_format = 'Respond with JSON: {"related_concepts": ["Concept A", "Concept B", ...]}'
    prompt = f"""
    Which of the following concepts from the entire ontology relate to the given {node_type}?
    Select all that apply, from the most specific to the most abstract.

    {node_type.capitalize()}: "{node_name}"

    Available Concepts:
    {json.dumps(all_concept_names, indent=2)}

    {json_format}
    """
    response = get_llm_response(prompt, 
                                model=model, 
                                provider=provider, 
                                format="json", 
                                npc=npc,
                                context=context, 
                                **kwargs)
    return response["response"].get("related_concepts", [])

Identifies which existing facts are causally or thematically related to a new fact.

Source code in npcpy/llm_funcs.py
def get_related_facts_llm(new_fact_statement, 
                          existing_fact_statements, 
                          model = None, 
                          provider = None,
                          npc = None, 
                          attempt_number = 1,
                          n_attempts = 3,
                          context='', 
                          **kwargs):
    """Identifies which existing facts are causally or thematically related to a new fact."""
    prompt = f"""
    A new fact has been learned: "{new_fact_statement}"

    Which of the following existing facts are directly related to it (causally, sequentially, or thematically)?
    Select only the most direct and meaningful connections.

    Existing Facts:
    {json.dumps(existing_fact_statements, indent=2)}

    Respond with JSON:
    """ + '{"related_facts": ["statement of a related fact", ...]}'
    response = get_llm_response(prompt,
                                model=model, 
                                provider=provider, 
                                format="json", 
                                npc=npc,
                                context=context,
                                **kwargs)   
    if attempt_number <= n_attempts:
        if not response["response"].get("related_facts", []):
            print(f"  Attempt {attempt_number} to find related facts yielded no results. Retrying...")
            return get_related_facts_llm(new_fact_statement, 
                                           existing_fact_statements, 
                                           model=model, 
                                           provider=provider, 
                                           npc=npc,
                                           attempt_number=attempt_number+1,
                                           n_attempts=n_attempts,
                                           context=context,
                                           **kwargs)    

    return response["response"].get("related_facts", [])

kg_add_concept(engine, concept_name, concept_description, npc=None, team=None, model=None, provider=None)

Add a new concept to the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_add_concept(
   engine,
   concept_name: str,
   concept_description: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Add a new concept to the knowledge graph"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   new_concept = {
       "name": concept_name,
       "description": concept_description,
       "generation": kg_data.get('generation', 0)
   }

   kg_data['concepts'].append(new_concept)
   save_kg_to_db(engine, kg_data, team_name, npc_name, directory_path)

   return f"Added concept: {concept_name}"

kg_add_fact(engine, fact_text, npc=None, team=None, model=None, provider=None)

Add a new fact to the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_add_fact(
   engine,
   fact_text: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Add a new fact to the knowledge graph"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   new_fact = {
       "statement": fact_text,
       "source_text": fact_text,
       "type": "manual",
       "generation": kg_data.get('generation', 0),
       "origin": "manual_add"
   }

   kg_data['facts'].append(new_fact)
   save_kg_to_db(engine, kg_data, team_name, npc_name, directory_path)

   return f"Added fact: {fact_text}"

kg_backfill_from_memories(engine, model=None, provider=None, npc=None, get_concepts=True, link_concepts_facts=False, link_concepts_concepts=False, link_facts_facts=False, dry_run=False, context='')

Backfill KG from approved memories that haven't been incorporated yet.

Source code in npcpy/memory/knowledge_graph.py
def kg_backfill_from_memories(
    engine,
    model: str = None,
    provider: str = None,
    npc=None,
    get_concepts: bool = True,
    link_concepts_facts: bool = False,
    link_concepts_concepts: bool = False,
    link_facts_facts: bool = False,
    dry_run: bool = False,
    context: str = ''
):
    """Backfill KG from approved memories that haven't been incorporated yet."""
    from sqlalchemy import text

    stats = {
        'scopes_processed': 0,
        'facts_before': 0,
        'facts_after': 0,
        'concepts_before': 0,
        'concepts_after': 0,
        'scopes': []
    }

    with engine.connect() as conn:
        stats['facts_before'] = conn.execute(text("SELECT COUNT(*) FROM kg_facts")).scalar() or 0
        stats['concepts_before'] = conn.execute(text("SELECT COUNT(*) FROM kg_concepts")).scalar() or 0

    with engine.connect() as conn:
        result = conn.execute(text("""
            SELECT id, npc, team, directory_path, initial_memory, final_memory
            FROM memory_lifecycle
            WHERE status IN ('human-approved', 'human-edited')
            ORDER BY npc, team, directory_path
        """))

        from collections import defaultdict
        memories_by_scope = defaultdict(list)
        for row in result:
            statement = row.final_memory or row.initial_memory
            scope = (row.npc or 'default', row.team or 'global_team', row.directory_path or os.getcwd())
            memories_by_scope[scope].append({
                'statement': statement,
                # Memory IS the fact — keep source_text populated so any existing consumer that
                # only reads source_text still gets the memory text.
                'source_text': statement,
                'memory_id': row.id,
                'type': 'explicit',
                'generation': 0
            })

    if dry_run:
        for scope, facts in memories_by_scope.items():
            stats['scopes'].append({
                'scope': scope,
                'memory_count': len(facts)
            })
        stats['scopes_processed'] = len(memories_by_scope)
        return stats

    for (npc_name, team_name, directory_path), facts in memories_by_scope.items():
        existing_kg = load_kg_from_db(engine, team_name, npc_name, directory_path)

        existing_statements = {f['statement'] for f in existing_kg.get('facts', [])}
        new_facts = [f for f in facts if f['statement'] not in existing_statements]

        if not new_facts:
            continue

        try:
            evolved_kg, _ = kg_evolve_incremental(
                existing_kg=existing_kg,
                new_facts=new_facts,
                model=model or (npc.model if npc else None),
                provider=provider or (npc.provider if npc else None),
                npc=npc,
                context=context,
                get_concepts=get_concepts,
                link_concepts_facts=link_concepts_facts,
                link_concepts_concepts=link_concepts_concepts,
                link_facts_facts=link_facts_facts
            )
            save_kg_to_db(engine, evolved_kg, team_name, npc_name, directory_path)

            stats['scopes'].append({
                'scope': (npc_name, team_name, directory_path),
                'facts_added': len(new_facts),
                'concepts_added': len(evolved_kg.get('concepts', [])) - len(existing_kg.get('concepts', []))
            })
            stats['scopes_processed'] += 1

        except Exception as e:
            logger.warning(f"Error processing scope {npc_name}/{team_name}: {e}")

    with engine.connect() as conn:
        stats['facts_after'] = conn.execute(text("SELECT COUNT(*) FROM kg_facts")).scalar() or 0
        stats['concepts_after'] = conn.execute(text("SELECT COUNT(*) FROM kg_concepts")).scalar() or 0

    return stats

kg_dream_process(existing_kg, model=None, provider=None, npc=None, context='', num_seeds=3)

Source code in npcpy/memory/knowledge_graph.py
def kg_dream_process(existing_kg,
                     model=None,
                     provider=None,
                     npc=None,
                     context='',
                     num_seeds=3):
    current_gen = existing_kg.get('generation', 0)
    next_gen = current_gen + 1
    logger.info(f"DREAMING (Creative Synthesis): Gen {current_gen} -> Gen {next_gen}")
    concepts = existing_kg.get('concepts', [])
    if len(concepts) < num_seeds:
        logger.info(f"Not enough concepts ({len(concepts)}) for dream. Skipping.")
        return existing_kg, {}
    seed_concepts = random.sample(concepts, k=num_seeds)
    seed_names = [c['name'] for c in seed_concepts]
    logger.info(f"Dream seeded with: {seed_names}")
    prompt = f"""
    Write a short, speculative paragraph (a 'dream') that plausibly connects the concepts of {json.dumps(seed_names)}.
    Invent a brief narrative or a hypothetical situation.
    Respond with JSON: {{"dream_text": "A short paragraph..."}}
    """
    response = get_llm_response(prompt,
                                model=model,
                                provider=provider, npc=npc,
                                format="json", context=context)
    dream_text = response['response'].get('dream_text')
    if not dream_text:
        logger.info("Failed to generate a dream narrative. Skipping.")
        return existing_kg, {}
    logger.info(f"Generated Dream: '{dream_text[:150]}...'")

    dream_kg, _ = kg_evolve_incremental(existing_kg, new_content_text=dream_text,
                                         model=model, provider=provider, npc=npc, context=context)

    original_fact_stmts = {f['statement'] for f in existing_kg['facts']}
    for fact in dream_kg['facts']:
        if fact['statement'] not in original_fact_stmts:
            fact['origin'] = 'dream'
    original_concept_names = {c['name'] for c in existing_kg['concepts']}
    for concept in dream_kg['concepts']:
        if concept['name'] not in original_concept_names:
            concept['origin'] = 'dream'
    logger.info("Dream analysis complete. New knowledge integrated.")
    return dream_kg, {}

Semantic search using embeddings via brute-force cosine similarity.

Source code in npcpy/memory/knowledge_graph.py
def kg_embedding_search(
    engine,
    query: str,
    npc=None,
    team=None,
    embedding_model: str = None,
    embedding_provider: str = None,
    similarity_threshold: float = 0.6,
    max_results: int = 20,
    include_concepts: bool = True,
    search_all_scopes: bool = True,
):
    """Semantic search using embeddings via brute-force cosine similarity."""
    from sqlalchemy import text

    try:
        from npcpy.gen.embeddings import get_embeddings
    except ImportError:
        logger.warning("Embeddings not available, falling back to keyword search")
        facts = kg_search_facts(engine, query, npc=npc, team=team,
                               search_all_scopes=search_all_scopes)
        return [{'content': f, 'type': 'fact', 'score': 0.5} for f in facts[:max_results]]

    model = embedding_model or 'nomic-embed-text'
    provider = embedding_provider or 'ollama'

    team_name = getattr(team, 'name', None) if team else None
    npc_name = getattr(npc, 'name', None) if npc else None

    results = []

    query_embedding = np.array(get_embeddings([query], model, provider)[0])

    with engine.connect() as conn:
        if search_all_scopes:
            fact_rows = conn.execute(text(
                "SELECT DISTINCT statement FROM kg_facts"
            )).fetchall()
        else:
            t_name = team_name or 'global_team'
            n_name = npc_name or 'default_npc'
            fact_rows = conn.execute(text("""
                SELECT statement FROM kg_facts
                WHERE team_name = :team AND npc_name = :npc
            """), {"team": t_name, "npc": n_name}).fetchall()

        if fact_rows:
            statements = [r.statement for r in fact_rows]
            embeddings = get_embeddings(statements, model, provider)

            for i, stmt in enumerate(statements):
                emb = np.array(embeddings[i])
                norm_p = np.linalg.norm(query_embedding) * np.linalg.norm(emb)
                if norm_p > 0:
                    sim = float(np.dot(query_embedding, emb) / norm_p)
                    if sim >= similarity_threshold:
                        results.append({'content': stmt, 'type': 'fact', 'score': sim})

        if include_concepts:
            if search_all_scopes:
                concept_rows = conn.execute(text(
                    "SELECT DISTINCT name FROM kg_concepts"
                )).fetchall()
            else:
                concept_rows = conn.execute(text("""
                    SELECT name FROM kg_concepts
                    WHERE team_name = :team AND npc_name = :npc
                """), {"team": t_name, "npc": n_name}).fetchall()

            if concept_rows:
                names = [r.name for r in concept_rows]
                embeddings = get_embeddings(names, model, provider)

                for i, name in enumerate(names):
                    emb = np.array(embeddings[i])
                    norm_p = np.linalg.norm(query_embedding) * np.linalg.norm(emb)
                    if norm_p > 0:
                        sim = float(np.dot(query_embedding, emb) / norm_p)
                        if sim >= similarity_threshold:
                            results.append({'content': name, 'type': 'concept', 'score': sim})

    results.sort(key=lambda x: -x['score'])
    return results[:max_results]

kg_evolve_incremental(existing_kg, new_content_text=None, new_facts=None, model=None, provider=None, npc=None, context='', get_concepts=False, link_concepts_facts=False, link_concepts_concepts=False, link_facts_facts=False, embedding_model=None, embedding_provider=None)

Source code in npcpy/memory/knowledge_graph.py
def kg_evolve_incremental(existing_kg,
                          new_content_text=None,
                          new_facts=None,
                          model=None,
                          provider=None,
                          npc=None,
                          context='',
                          get_concepts=False,
                          link_concepts_facts=False,
                          link_concepts_concepts=False,
                          link_facts_facts=False,
                          embedding_model=None,
                          embedding_provider=None):

    current_gen = existing_kg.get('generation', 0)
    next_gen = current_gen + 1

    newly_added_concepts = []
    concept_links = list(existing_kg.get('concept_links', []))
    fact_to_concept_links = defaultdict(list,
                                        existing_kg.get('fact_to_concept_links', {}))
    fact_to_fact_links = list(existing_kg.get('fact_to_fact_links', []))

    existing_facts = existing_kg.get('facts', [])
    existing_concepts = existing_kg.get('concepts', [])
    existing_concept_names = {c['name'] for c in existing_concepts}
    existing_fact_statements = [f['statement'] for f in existing_facts]
    all_concept_names = list(existing_concept_names)

    all_new_facts = []

    if new_facts:
        all_new_facts = new_facts
        logger.info(f'Using pre-approved facts: {len(all_new_facts)}')
    elif new_content_text:
        logger.info('Extracting facts from content...')
        if len(new_content_text) > 10000:
            for n in range(len(new_content_text) // 10000):
                content_to_sample = new_content_text[n * 10000:(n + 1) * 10000]
                facts = get_facts(content_to_sample,
                                model=model,
                                provider=provider,
                                npc=npc,
                                context=context)
                all_new_facts.extend(facts)
        else:
            all_new_facts = get_facts(new_content_text,
                                model=model,
                                provider=provider,
                                npc=npc,
                                context=context)
    else:
        logger.info("No new content or facts provided")
        return existing_kg, {}

    existing_stmts = {f['statement'] for f in existing_facts}
    for fact in all_new_facts:
        fact['generation'] = next_gen

    final_facts = existing_facts + [f for f in all_new_facts if f['statement'] not in existing_stmts]

    if get_concepts:
        logger.info('Generating groups...')
        candidate_concepts = generate_groups(all_new_facts,
                                            model=model,
                                            provider=provider,
                                            npc=npc,
                                            context=context)
        for cand_concept in candidate_concepts:
            cand_name = cand_concept['name']
            if cand_name in existing_concept_names:
                continue
            cand_concept['generation'] = next_gen
            newly_added_concepts.append(cand_concept)
            if link_concepts_concepts:
                related_concepts = get_related_concepts_multi(cand_name,
                                                            "concept",
                                                            all_concept_names,
                                                            model,
                                                            provider,
                                                            npc,
                                                            context)
                for related_name in related_concepts:
                    if related_name != cand_name:
                        concept_links.append((cand_name, related_name))
            all_concept_names.append(cand_name)

        final_concepts = existing_concepts + newly_added_concepts

        if link_concepts_facts:
            for fact in all_new_facts:
                fact_to_concept_links[fact['statement']] = get_related_concepts_multi(
                    fact['statement'], "fact", all_concept_names,
                    model=model, provider=provider, npc=npc, context=context)
    else:
        final_concepts = existing_concepts

    if link_facts_facts and existing_fact_statements:
        e_model = embedding_model or 'nomic-embed-text'
        e_provider = embedding_provider or 'ollama'

        for new_fact in all_new_facts:
            # Pre-filter existing facts by embedding similarity
            candidates = _get_similar_by_embedding(
                new_fact['statement'], existing_fact_statements,
                e_model, e_provider, top_k=20)
            if candidates:
                related_fact_stmts = get_related_facts_llm(new_fact['statement'],
                                                           candidates,
                                                           model=model,
                                                           provider=provider,
                                                           npc=npc,
                                                           context=context)
                for related_stmt in related_fact_stmts:
                    fact_to_fact_links.append((new_fact['statement'], related_stmt))

    final_kg = {
        "generation": next_gen,
        "facts": final_facts,
        "concepts": final_concepts,
        "concept_links": concept_links,
        "fact_to_concept_links": dict(fact_to_concept_links),
        "fact_to_fact_links": fact_to_fact_links
    }
    return final_kg, {}

kg_evolve_knowledge(engine, content_text, npc=None, team=None, model=None, provider=None)

Evolve the knowledge graph with new content

Source code in npcpy/memory/knowledge_graph.py
def kg_evolve_knowledge(
   engine,
   content_text: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Evolve the knowledge graph with new content"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   evolved_kg, _ = kg_evolve_incremental(
       existing_kg=kg_data,
       new_content_text=content_text,
       model=npc.model if npc else model,
       provider=npc.provider if npc else provider,
       npc=npc,
       get_concepts=True,
       link_concepts_facts=False,
       link_concepts_concepts=False,
       link_facts_facts=False
   )

   save_kg_to_db(engine, evolved_kg, team_name, npc_name, directory_path)

   return "Knowledge graph evolved with new content"

kg_explore_concept(engine, concept_name, max_depth=2, breadth_per_step=10, search_all_scopes=True)

Explore all facts and related concepts for a given concept.

Source code in npcpy/memory/knowledge_graph.py
def kg_explore_concept(
    engine,
    concept_name: str,
    max_depth: int = 2,
    breadth_per_step: int = 10,
    search_all_scopes: bool = True
):
    """Explore all facts and related concepts for a given concept."""
    from sqlalchemy import text

    result = {
        'concept': concept_name,
        'direct_facts': [],
        'related_concepts': [],
        'extended_facts': []
    }

    with engine.connect() as conn:
        rows = conn.execute(text("""
            SELECT source FROM kg_links
            WHERE target = :concept AND type = 'fact_to_concept'
        """), {"concept": concept_name})
        result['direct_facts'] = [r.source for r in rows]

        rows = conn.execute(text("""
            SELECT target FROM kg_links
            WHERE source = :concept AND type = 'concept_to_concept'
            UNION
            SELECT source FROM kg_links
            WHERE target = :concept AND type = 'concept_to_concept'
        """), {"concept": concept_name})
        result['related_concepts'] = [r[0] for r in rows]

        if result['related_concepts'] and max_depth > 0:
            placeholders = ','.join([f':c{i}' for i in range(len(result['related_concepts']))])
            params = {f'c{i}': c for i, c in enumerate(result['related_concepts'])}

            rows = conn.execute(text(f"""
                SELECT DISTINCT source FROM kg_links
                WHERE target IN ({placeholders}) AND type = 'fact_to_concept'
            """), params)
            result['extended_facts'] = [r.source for r in rows
                                        if r.source not in result['direct_facts']]

    return result

kg_get_all_facts(engine, npc=None, team=None, model=None, provider=None, search_all_scopes=True)

Get all facts from the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_get_all_facts(
   engine,
   npc=None,
   team=None,
   model=None,
   provider=None,
   search_all_scopes=True
):
   """Get all facts from the knowledge graph"""
   from sqlalchemy import text

   directory_path = os.getcwd()
   team_name = getattr(team, 'name', None) if team else None
   npc_name = getattr(npc, 'name', None) if npc else None

   if search_all_scopes and (not team_name or not npc_name):
       with engine.connect() as conn:
           result = conn.execute(text("SELECT DISTINCT statement FROM kg_facts"))
           return [row.statement for row in result]
   else:
       if not team_name:
           team_name = 'global_team'
       if not npc_name:
           npc_name = 'default_npc'
       kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)
       return [f['statement'] for f in kg_data.get('facts', [])]

kg_get_facts_for_concept(engine, concept_name, npc=None, team=None, model=None, provider=None)

Get all facts linked to a specific concept

Source code in npcpy/memory/knowledge_graph.py
def kg_get_facts_for_concept(
   engine,
   concept_name: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Get all facts linked to a specific concept"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   fact_to_concept_links = kg_data.get('fact_to_concept_links', {})
   linked_facts = []

   for fact_statement, linked_concepts in fact_to_concept_links.items():
       if concept_name in linked_concepts:
           linked_facts.append(fact_statement)

   return linked_facts

kg_get_stats(engine, npc=None, team=None, model=None, provider=None)

Get statistics about the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_get_stats(
   engine,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Get statistics about the knowledge graph"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   return {
       "total_facts": len(kg_data.get('facts', [])),
       "total_concepts": len(kg_data.get('concepts', [])),
       "total_fact_concept_links": len(kg_data.get('fact_to_concept_links', {})),
       "generation": kg_data.get('generation', 0)
   }

Hybrid search combining multiple methods.

Source code in npcpy/memory/knowledge_graph.py
def kg_hybrid_search(
    engine,
    query: str,
    npc=None,
    team=None,
    mode: str = 'keyword+link',
    max_depth: int = 2,
    breadth_per_step: int = 5,
    max_results: int = 20,
    embedding_model: str = None,
    embedding_provider: str = None,
    similarity_threshold: float = 0.6,
    search_all_scopes: bool = True
):
    """Hybrid search combining multiple methods."""
    all_results = {}

    if 'keyword' in mode or mode == 'link' or mode == 'all':
        keyword_facts = kg_search_facts(engine, query, npc=npc, team=team,
                                        search_all_scopes=search_all_scopes)
        for f in keyword_facts:
            all_results[f] = {'content': f, 'type': 'fact', 'score': 0.7, 'source': 'keyword'}

    if 'embedding' in mode or mode == 'all':
        try:
            emb_results = kg_embedding_search(
                engine, query, npc=npc, team=team,
                embedding_model=embedding_model,
                embedding_provider=embedding_provider,
                similarity_threshold=similarity_threshold,
                max_results=max_results,
                search_all_scopes=search_all_scopes
            )
            for r in emb_results:
                if r['content'] in all_results:
                    all_results[r['content']]['score'] = max(
                        all_results[r['content']]['score'], r['score']
                    ) * 1.1
                    all_results[r['content']]['source'] += '+embedding'
                else:
                    r['source'] = 'embedding'
                    all_results[r['content']] = r
        except Exception as e:
            logger.warning(f"Embedding search failed: {e}")

    if 'link' in mode or mode == 'all':
        link_results = kg_link_search(
            engine, query, npc=npc, team=team,
            max_depth=max_depth,
            breadth_per_step=breadth_per_step,
            max_results=max_results,
            search_all_scopes=search_all_scopes
        )
        for r in link_results:
            if r['content'] in all_results:
                all_results[r['content']]['score'] = max(
                    all_results[r['content']]['score'], r['score']
                ) * 1.05
                all_results[r['content']]['source'] += '+link'
                all_results[r['content']]['depth'] = r.get('depth', 0)
                all_results[r['content']]['path'] = r.get('path', [])
            else:
                r['source'] = 'link'
                all_results[r['content']] = r

    final = sorted(all_results.values(), key=lambda x: -x['score'])
    return final[:max_results]

kg_initial(content, model=None, provider=None, npc=None, context='', facts=None, generation=None, verbose=True, embedding_model=None, embedding_provider=None, zoom_in_enabled=True)

Source code in npcpy/memory/knowledge_graph.py
def kg_initial(content,
               model=None,
               provider=None,
               npc=None,
               context='',
               facts=None,
               generation=None,
               verbose=True,
               embedding_model=None,
               embedding_provider=None,
               zoom_in_enabled=True):

    if generation is None:
        CURRENT_GENERATION = 0
    else:
        CURRENT_GENERATION = generation

    logger.info(f"Running KG Structuring Process (Generation: {CURRENT_GENERATION})")

    if facts is None:
        if not content:
            raise ValueError("kg_initial requires either content_text or a list of facts.")
        logger.info("Mode: Deriving new facts from text content...")
        all_facts = []
        if len(content) > 10000:
            for n in range(len(content) // 10000):
                content_to_sample = content[n * 10000:(n + 1) * 10000]
                extracted = get_facts(content_to_sample,
                                model=model,
                                provider=provider,
                                npc=npc,
                                context=context)
                if verbose:
                    logger.debug(f"Extracted {len(extracted)} facts from segment {n+1}")
                all_facts.extend(extracted)
        else:
            all_facts = get_facts(content,
                                  model=model,
                                  provider=provider,
                                  npc=npc,
                                  context=context)
            if verbose:
                logger.debug(f"Extracted {len(all_facts)} facts from content")
        for fact in all_facts:
            fact['generation'] = CURRENT_GENERATION
    else:
        logger.info(f"Mode: Building structure from {len(facts)} pre-existing facts...")
        all_facts = list(facts)

    all_implied_facts = []
    if zoom_in_enabled:
        logger.info("Inferring implied facts (zooming in)...")
        if len(all_facts) > 20:
            sampled_facts = random.sample(all_facts, k=20)
            for n in range(len(all_facts) // 20):
                implied_facts = zoom_in(sampled_facts,
                                        model=model,
                                        provider=provider,
                                        npc=npc,
                                        context=context)
                all_implied_facts.extend(implied_facts)
                if verbose:
                    logger.debug(f"Inferred {len(implied_facts)} implied facts from sample {n+1}")
        else:
            implied_facts = zoom_in(all_facts,
                                    model=model,
                                    provider=provider,
                                    npc=npc,
                                    context=context)
            all_implied_facts.extend(implied_facts)
            if verbose:
                logger.debug(f"Inferred {len(implied_facts)} implied facts from all facts")

        for fact in all_implied_facts:
            fact['generation'] = CURRENT_GENERATION
    else:
        logger.info("Skipping zoom-in (zoom_in_enabled=False)")

    all_facts = all_facts + all_implied_facts

    logger.info("Generating concepts from all facts...")
    concepts = generate_groups(all_facts,
                               model=model,
                               provider=provider,
                               npc=npc,
                               context=context)
    for concept in concepts:
        concept['generation'] = CURRENT_GENERATION

    if verbose:
        logger.debug(f"Generated {len(concepts)} concepts")

    logger.info("Linking facts to concepts...")
    fact_to_concept_links = defaultdict(list)
    concept_names = [c['name'] for c in concepts if c and 'name' in c]
    for fact in all_facts:
        fact_to_concept_links[fact['statement']] = get_related_concepts_multi(
            fact['statement'], "fact", concept_names, model, provider, npc, context)

    logger.info("Linking facts to other facts...")
    fact_to_fact_links = []
    fact_statements = [f['statement'] for f in all_facts]

    e_model = embedding_model or 'nomic-embed-text'
    e_provider = embedding_provider or 'ollama'

    for i, fact in enumerate(all_facts):
        other_fact_statements = [s for s in fact_statements if s != fact['statement']]
        if not other_fact_statements:
            continue
        try:
            # Pre-filter by embedding similarity to avoid passing too many to LLM
            candidates = _get_similar_by_embedding(
                fact['statement'], other_fact_statements, e_model, e_provider, top_k=20)
            if candidates:
                related_fact_stmts = get_related_facts_llm(fact['statement'],
                                                           candidates,
                                                           model=model,
                                                           provider=provider,
                                                           npc=npc,
                                                           context=context)
                for related_stmt in related_fact_stmts:
                    fact_to_fact_links.append((fact['statement'], related_stmt))
        except Exception as e:
            logger.warning(f"Failed to link fact {i+1}/{len(all_facts)}: {e}")
            continue

    return {
        "generation": CURRENT_GENERATION,
        "facts": all_facts,
        "concepts": concepts,
        "concept_links": [],
        "fact_to_concept_links": dict(fact_to_concept_links),
        "fact_to_fact_links": fact_to_fact_links
    }

Link a fact to a concept in the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_link_fact_to_concept(
   engine,
   fact_text: str,
   concept_name: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Link a fact to a concept in the knowledge graph"""
   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)

   fact_to_concept_links = kg_data.get('fact_to_concept_links', {})

   if fact_text not in fact_to_concept_links:
       fact_to_concept_links[fact_text] = []

   if concept_name not in fact_to_concept_links[fact_text]:
       fact_to_concept_links[fact_text].append(concept_name)
       kg_data['fact_to_concept_links'] = fact_to_concept_links
       save_kg_to_db(engine, kg_data, team_name, npc_name, directory_path)
       return f"Linked fact '{fact_text}' to concept '{concept_name}'"

   return "Fact already linked to concept"

Search KG by traversing links from keyword-matched seeds.

Source code in npcpy/memory/knowledge_graph.py
def kg_link_search(
    engine,
    query: str,
    npc=None,
    team=None,
    max_depth: int = 2,
    breadth_per_step: int = 5,
    max_results: int = 20,
    strategy: str = 'bfs',
    search_all_scopes: bool = True
):
    """Search KG by traversing links from keyword-matched seeds."""
    from sqlalchemy import text
    from collections import deque

    seeds = kg_search_facts(engine, query, npc=npc, team=team,
                           search_all_scopes=search_all_scopes)

    if not seeds:
        return []

    visited = set(seeds[:breadth_per_step])
    results = [{'content': s, 'type': 'fact', 'depth': 0, 'path': [s], 'score': 1.0}
               for s in seeds[:breadth_per_step]]

    if strategy == 'bfs':
        queue = deque()
        for seed in seeds[:breadth_per_step]:
            queue.append((seed, 'fact', 0, [seed], 1.0))
    else:
        queue = []
        for seed in seeds[:breadth_per_step]:
            queue.append((seed, 'fact', 0, [seed], 1.0))

    with engine.connect() as conn:
        while queue and len(results) < max_results:
            if strategy == 'bfs':
                current, curr_type, depth, path, score = queue.popleft()
            else:
                current, curr_type, depth, path, score = queue.pop()

            if depth >= max_depth:
                continue

            linked = []

            result = conn.execute(text("""
                SELECT target, type FROM kg_links WHERE source = :src
            """), {"src": current})
            for row in result:
                target_type = 'concept' if 'concept' in row.type else 'fact'
                linked.append((row.target, target_type, row.type))

            result = conn.execute(text("""
                SELECT source, type FROM kg_links WHERE target = :tgt
            """), {"tgt": current})
            for row in result:
                source_type = 'fact' if 'fact_to' in row.type else 'concept'
                linked.append((row.source, source_type, f"rev_{row.type}"))

            added = 0
            for item_content, item_type, link_type in linked:
                if item_content in visited or added >= breadth_per_step:
                    continue

                visited.add(item_content)
                new_path = path + [item_content]
                new_score = score * 0.8

                results.append({
                    'content': item_content,
                    'type': item_type,
                    'depth': depth + 1,
                    'path': new_path,
                    'score': new_score,
                    'link_type': link_type
                })

                queue.append((item_content, item_type, depth + 1, new_path, new_score))
                added += 1

    results.sort(key=lambda x: (-x['score'], x['depth']))
    return results[:max_results]

kg_list_concepts(engine, npc=None, team=None, model=None, provider=None, search_all_scopes=True)

List all concepts in the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_list_concepts(
   engine,
   npc=None,
   team=None,
   model=None,
   provider=None,
   search_all_scopes=True
):
   """List all concepts in the knowledge graph"""
   from sqlalchemy import text

   directory_path = os.getcwd()
   team_name = getattr(team, 'name', None) if team else None
   npc_name = getattr(npc, 'name', None) if npc else None

   if search_all_scopes and (not team_name or not npc_name):
       with engine.connect() as conn:
           result = conn.execute(text("SELECT DISTINCT name FROM kg_concepts"))
           return [row.name for row in result]
   else:
       if not team_name:
           team_name = 'global_team'
       if not npc_name:
           npc_name = 'default_npc'
       kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)
       return [c['name'] for c in kg_data.get('concepts', [])]

kg_remove_concept(engine, concept_name, npc=None, team=None, model=None, provider=None)

Remove a concept from the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_remove_concept(
   engine,
   concept_name: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Remove a concept from the knowledge graph"""
   from sqlalchemy import text as sa_text

   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   with engine.begin() as conn:
       result = conn.execute(sa_text("""
           DELETE FROM kg_concepts
           WHERE name = :name AND team_name = :team_name
           AND npc_name = :npc_name AND directory_path = :directory_path
       """), {
           "name": concept_name,
           "team_name": team_name,
           "npc_name": npc_name,
           "directory_path": directory_path
       })
       removed_count = result.rowcount

   if removed_count > 0:
       # Also remove any links referencing this concept
       with engine.begin() as conn:
           conn.execute(sa_text("""
               DELETE FROM kg_links
               WHERE (source = :concept OR target = :concept)
               AND team_name = :team_name AND npc_name = :npc_name
               AND directory_path = :directory_path
           """), {
               "concept": concept_name,
               "team_name": team_name,
               "npc_name": npc_name,
               "directory_path": directory_path
           })
       return f"Removed concept: {concept_name}"

   return "Concept not found"

kg_remove_fact(engine, fact_text, npc=None, team=None, model=None, provider=None)

Remove a fact from the knowledge graph

Source code in npcpy/memory/knowledge_graph.py
def kg_remove_fact(
   engine,
   fact_text: str,
   npc=None,
   team=None,
   model=None,
   provider=None
):
   """Remove a fact from the knowledge graph"""
   from sqlalchemy import text as sa_text

   directory_path = os.getcwd()
   team_name = getattr(team, 'name', 'default_team') if team else 'default_team'
   npc_name = npc.name if npc else 'default_npc'

   with engine.begin() as conn:
       result = conn.execute(sa_text("""
           DELETE FROM kg_facts
           WHERE statement = :statement AND team_name = :team_name
           AND npc_name = :npc_name AND directory_path = :directory_path
       """), {
           "statement": fact_text,
           "team_name": team_name,
           "npc_name": npc_name,
           "directory_path": directory_path
       })
       removed_count = result.rowcount

   if removed_count > 0:
       # Also remove any links referencing this fact
       with engine.begin() as conn:
           conn.execute(sa_text("""
               DELETE FROM kg_links
               WHERE (source = :fact OR target = :fact)
               AND team_name = :team_name AND npc_name = :npc_name
               AND directory_path = :directory_path
           """), {
               "fact": fact_text,
               "team_name": team_name,
               "npc_name": npc_name,
               "directory_path": directory_path
           })
       return f"Removed {removed_count} matching fact(s)"

   return "No matching facts found"

kg_search_facts(engine, query, npc=None, team=None, model=None, provider=None, search_all_scopes=True)

Search facts in the knowledge graph by keyword.

Source code in npcpy/memory/knowledge_graph.py
def kg_search_facts(
   engine,
   query: str,
   npc=None,
   team=None,
   model=None,
   provider=None,
   search_all_scopes=True
):
   """Search facts in the knowledge graph by keyword."""
   from sqlalchemy import text

   directory_path = os.getcwd()
   team_name = getattr(team, 'name', None) if team else None
   npc_name = getattr(npc, 'name', None) if npc else None

   matching_facts = []

   if search_all_scopes and (not team_name or not npc_name):
       with engine.connect() as conn:
           result = conn.execute(text("""
               SELECT DISTINCT statement FROM kg_facts
               WHERE LOWER(statement) LIKE LOWER(:query)
           """), {"query": f"%{query}%"})
           matching_facts = [row.statement for row in result]
   else:
       if not team_name:
           team_name = 'global_team'
       if not npc_name:
           npc_name = 'default_npc'
       kg_data = load_kg_from_db(engine, team_name, npc_name, directory_path)
       for fact in kg_data.get('facts', []):
           if query.lower() in fact['statement'].lower():
               matching_facts.append(fact['statement'])

   return matching_facts

kg_sleep_process(existing_kg, model=None, provider=None, npc=None, context='', operations_config=None, embedding_model=None, embedding_provider=None)

Source code in npcpy/memory/knowledge_graph.py
def kg_sleep_process(existing_kg,
                     model=None,
                     provider=None,
                     npc=None,
                     context='',
                     operations_config=None,
                     embedding_model=None,
                     embedding_provider=None):
    current_gen = existing_kg.get('generation', 0)
    next_gen = current_gen + 1
    logger.info(f"SLEEPING (Evolving Knowledge): Gen {current_gen} -> Gen {next_gen}")

    facts_map = {f['statement']: f for f in existing_kg.get('facts', [])}
    concepts_map = {c['name']: c for c in existing_kg.get('concepts', [])}
    fact_links = defaultdict(list, {k: list(v) for k, v in existing_kg.get('fact_to_concept_links', {}).items()})
    concept_links = set(tuple(sorted(link)) for link in existing_kg.get('concept_links', []))
    fact_to_fact_links = set(tuple(sorted(link)) for link in existing_kg.get('fact_to_fact_links', []))

    # Phase 1: Check for unstructured facts
    logger.info("Phase 1: Checking for unstructured facts...")
    facts_with_concepts = set(fact_links.keys())
    orphaned_fact_statements = list(set(facts_map.keys()) - facts_with_concepts)

    if len(orphaned_fact_statements) > 20:
        logger.info(f"Found {len(orphaned_fact_statements)} orphaned facts. Applying full KG structuring process...")
        orphaned_facts_as_dicts = [facts_map[s] for s in orphaned_fact_statements]

        new_structure = kg_initial(
            content=None,
            facts=orphaned_facts_as_dicts,
            model=model,
            provider=provider,
            npc=npc,
            context=context,
            generation=next_gen,
            embedding_model=embedding_model,
            embedding_provider=embedding_provider
        )

        logger.info("Merging new structure into main KG...")
        for concept in new_structure.get("concepts", []):
            if concept['name'] not in concepts_map:
                concepts_map[concept['name']] = concept

        for fact_stmt, new_links in new_structure.get("fact_to_concept_links", {}).items():
            existing_links = set(fact_links.get(fact_stmt, []))
            existing_links.update(new_links)
            fact_links[fact_stmt] = list(existing_links)

        for f1, f2 in new_structure.get("fact_to_fact_links", []):
            fact_to_fact_links.add(tuple(sorted((f1, f2))))
    else:
        logger.info("Knowledge graph is sufficiently structured. Proceeding to refinement.")

    # Phase 2: Refinement operations
    if operations_config is None:
        possible_ops = ['prune', 'deepen']
        ops_to_run = random.sample(possible_ops, k=random.randint(1, 2))
    else:
        ops_to_run = operations_config

    logger.info(f"Phase 2: Executing refinement operations: {ops_to_run}")

    for op in ops_to_run:
        if op == 'prune' and (len(facts_map) > 10 or len(concepts_map) > 5):
            logger.info("Running 'prune' operation...")
            fact_to_check = random.choice(list(facts_map.values()))
            other_facts = [f for f in facts_map.values() if f['statement'] != fact_to_check['statement']]
            consolidation_result = consolidate_facts_llm(fact_to_check, other_facts, model, provider, npc, context)
            if consolidation_result.get('decision') == 'redundant':
                logger.info(f"Pruning redundant fact: '{fact_to_check['statement'][:80]}...'")
                del facts_map[fact_to_check['statement']]

        elif op == 'deepen' and facts_map:
            logger.info("Running 'deepen' operation...")
            fact_to_deepen = random.choice(list(facts_map.values()))
            implied_facts = zoom_in([fact_to_deepen], model, provider, npc, context)
            new_fact_count = 0
            for fact in implied_facts:
                if fact['statement'] not in facts_map:
                    fact.update({'generation': next_gen, 'origin': 'deepen'})
                    facts_map[fact['statement']] = fact
                    new_fact_count += 1
            if new_fact_count > 0:
                logger.info(f"Inferred {new_fact_count} new fact(s).")

        else:
            logger.debug(f"SKIPPED: Operation '{op}' did not run (conditions not met).")

    new_kg = {
        "generation": next_gen,
        "facts": list(facts_map.values()),
        "concepts": list(concepts_map.values()),
        "concept_links": [list(link) for link in concept_links],
        "fact_to_concept_links": dict(fact_links),
        "fact_to_fact_links": [list(link) for link in fact_to_fact_links]
    }
    return new_kg, {}

prune_fact_subset_llm(fact_subset, concept_name, model=None, provider=None, npc=None, context=None, **kwargs)

Identifies redundancies WITHIN a small, topically related subset of facts.

Source code in npcpy/llm_funcs.py
def prune_fact_subset_llm(fact_subset, 
                          concept_name, 
                          model=None,
                          provider=None,
                          npc=None,
                          context : str = None,
                          **kwargs):
    """Identifies redundancies WITHIN a small, topically related subset of facts."""
    print(f"  Step Sleep-A: Pruning fact subset for concept '{concept_name}'...")


    prompt = f"""
    The following facts are all related to the concept "{concept_name}".
    Review ONLY this subset and identify groups of facts that are semantically identical.
    Return only the set of facts that are semantically distinct, and archive the rest.

    Fact Subset: {json.dumps(fact_subset, indent=2)}

    Return a json list of groups
    """ + '{"refined_facts": [fact1, fact2, fact3, ...]}'
    response = get_llm_response(prompt, 
                                model=model, 
                                provider=provider, 
                                npc=None,
                                format="json", 
                                context=context)
    return response['response'].get('refined_facts', [])

remove_idempotent_groups(group_candidates, model=None, provider=None, npc=None, context=None, **kwargs)

Remove groups that are essentially identical in meaning, favoring specificity and direct naming, and avoiding generic structures.

Source code in npcpy/llm_funcs.py
def remove_idempotent_groups(
    group_candidates: List[str],
    model: str = None,
    provider: str =None,
    npc = None, 
    context : str = None,
    **kwargs: Any
) -> List[str]:
    """Remove groups that are essentially identical in meaning, favoring specificity and direct naming, and avoiding generic structures."""

    prompt = f"""Compare these group names. Identify and list ONLY the groups that are conceptually distinct and specific.

    GUIDELINES FOR SELECTING DISTINCT GROUPS:
    1.  **Prioritize Specificity and Direct Naming:** Favor precise nouns or noun phrases that directly name the subject.
    2.  **Prefer Concrete Entities/Actions:** If a name refers to a specific entity or action (e.g., "Earth", "Sun", "Water", "France", "User Authentication Module", "Lamb Shank Braising", "World War I"), keep it if it's distinct.
    3.  **Rephrase Gerunds:** If a name uses a gerund (e.g., "Understanding TDEs"), rephrase it to a noun or noun phrase (e.g., "Tidal Disruption Events").
    4.  **AVOID OVERLY GENERIC TERMS:** Do NOT use very broad or abstract terms that don't add specific meaning. Examples to avoid: "Concepts", "Processes", "Dynamics", "Mechanics", "Analysis", "Understanding", "Interactions", "Relationships", "Properties", "Structures", "Systems", "Frameworks", "Predictions", "Outcomes", "Effects", "Considerations", "Methods", "Techniques", "Data", "Theoretical", "Physical", "Spatial", "Temporal". If a group name seems overly generic or abstract, it should likely be removed or refined.
    5.  **Similarity Check:** If two groups are very similar, keep the one that is more descriptive or specific to the domain.

    EXAMPLE 1:
    Groups: ["Accretion Disk Formation", "Accretion Disk Dynamics", "Formation of Accretion Disks"]
    Distinct Groups: ["Accretion Disk Formation", "Accretion Disk Dynamics"] 

    EXAMPLE 2:
    Groups: ["Causes of Events", "Event Mechanisms", "Event Drivers"]
    Distinct Groups: ["Event Causation", "Event Mechanisms"] 

    EXAMPLE 3:
    Groups: ["Astrophysics Basics", "Fundamental Physics", "General Science Concepts"]
    Distinct Groups: ["Fundamental Physics"] 

    EXAMPLE 4:
    Groups: ["Earth", "The Planet Earth", "Sun", "Our Star"]
    Distinct Groups: ["Earth", "Sun"]

    EXAMPLE 5:
    Groups: ["User Authentication Module", "Authentication System", "Login Process"]
    Distinct Groups: ["User Authentication Module", "Login Process"]

    ---

    Now, analyze the following groups:
    Groups: {json.dumps(group_candidates)}

    Return JSON:
    """ + '{"distinct_groups": ["list of specific, precise, and distinct group names to keep"]}'

    response = get_llm_response(
        prompt,
        model=model,
        provider=provider,
        format="json",
        npc=npc,
        context=context,
        **kwargs
    )

    return response["response"]["distinct_groups"]

save_changelog_to_json(changelog, from_gen, to_gen, path_prefix='changelog')

Source code in npcpy/memory/knowledge_graph.py
def save_changelog_to_json(changelog, from_gen, to_gen, path_prefix="changelog"):
    if not changelog:
        return
    with open(f"{path_prefix}_gen{from_gen}_to_{to_gen}.json", 'w', encoding='utf-8') as f:
        json.dump(changelog, f, indent=4)
    logger.info(f"Saved changelog for Gen {from_gen}->{to_gen}.")

save_kg_with_pandas(kg, path_prefix='kg_state')

Source code in npcpy/memory/knowledge_graph.py
def save_kg_with_pandas(kg, path_prefix="kg_state"):
    generation = kg.get("generation", 0)

    nodes_data = []
    for fact in kg.get('facts', []):
        nodes_data.append({'id': fact['statement'], 'type': 'fact', 'generation': fact.get('generation')})
    for concept in kg.get('concepts', []):
        nodes_data.append({'id': concept['name'], 'type': 'concept', 'generation': concept.get('generation')})
    pd.DataFrame(nodes_data).to_csv(f'{path_prefix}_gen{generation}_nodes.csv', index=False)

    links_data = []
    for fact_stmt, concepts in kg.get("fact_to_concept_links", {}).items():
        for concept_name in concepts:
            links_data.append({'source': fact_stmt, 'target': concept_name, 'type': 'fact_to_concept'})
    for c1, c2 in kg.get("concept_links", []):
        links_data.append({'source': c1, 'target': c2, 'type': 'concept_to_concept'})
    for f1, f2 in kg.get("fact_to_fact_links", []):
        links_data.append({'source': f1, 'target': f2, 'type': 'fact_to_fact'})
    pd.DataFrame(links_data).to_csv(f'{path_prefix}_gen{generation}_links.csv', index=False)
    logger.info(f"Saved KG Generation {generation} to CSV files.")

store_fact_with_embedding(collection, fact, metadata, embedding)

Store a fact with its pre-generated embedding in Chroma DB.

Source code in npcpy/memory/knowledge_graph.py
def store_fact_with_embedding(
    collection, fact: str, metadata: dict, embedding: List[float]
) -> str:
    """Store a fact with its pre-generated embedding in Chroma DB."""
    try:
        fact_id = hashlib.md5(fact.encode()).hexdigest()

        collection.add(
            documents=[fact],
            embeddings=[embedding],
            metadatas=[metadata],
            ids=[fact_id],
        )

        return fact_id
    except Exception as e:
        logger.warning(f"Error storing fact in Chroma: {e}")
        return None

zoom_in(facts, model=None, provider=None, npc=None, context=None, attempt_number=1, n_attempts=3, **kwargs)

Infer new implied facts from existing facts

Source code in npcpy/llm_funcs.py
def zoom_in(facts, 
            model= None,
            provider=None, 
            npc=None,
            context: str = None, 
            attempt_number: int = 1,
            n_attempts=3,            
            **kwargs):
    """Infer new implied facts from existing facts"""
    valid_facts = []
    for fact in facts:
        if isinstance(fact, dict) and 'statement' in fact:
            valid_facts.append(fact)
    if not valid_facts:
        return []     

    fact_lines = []
    for fact in valid_facts:
        fact_lines.append(f"- {fact['statement']}")
    facts_text = "\n".join(fact_lines)

    prompt = f"""
    Look at these facts and infer new implied facts:

    {facts_text}

    What other facts can be reasonably inferred from these?
    """ +"""
    Respond with JSON:
    {
        "implied_facts": [
            {
                "statement": "new implied fact",
                "inferred_from": ["which facts this comes from"]
            }
        ]
    }
    """

    response = get_llm_response(prompt, 
                                model=model, 
                                provider=provider, 
                                format="json", 
                                context=context,
                                npc=npc,
                                **kwargs)

    facts =  response.get("response", {}).get("implied_facts", [])
    if len(facts) == 0:
        return zoom_in(valid_facts, 
                       model=model, 
                       provider=provider, 
                       npc=npc,
                       context=context,
                       attempt_number=attempt_number+1,
                       n_tries=n_attempts,
                       **kwargs)
    return facts