Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2fa9ae1d | ||
|
|
7039ab44a7 | ||
|
|
1a2969724d | ||
|
|
3ce8e557e4 |
@@ -92,7 +92,6 @@ ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
@@ -169,9 +168,8 @@ DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refr
|
||||
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
|
||||
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
|
||||
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
|
||||
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
|
||||
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "verbose"
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
|
||||
|
||||
# Observations defaults (consolidated knowledge from facts)
|
||||
@@ -330,7 +328,6 @@ class HindsightConfig:
|
||||
retain_chunk_size: int
|
||||
retain_extract_causal_links: bool
|
||||
retain_extraction_mode: str
|
||||
retain_custom_instructions: str | None
|
||||
retain_observations_async: bool
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
@@ -434,7 +431,6 @@ class HindsightConfig:
|
||||
retain_extraction_mode=_validate_extraction_mode(
|
||||
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
|
||||
),
|
||||
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
|
||||
retain_observations_async=os.getenv(
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
|
||||
).lower()
|
||||
|
||||
@@ -2764,7 +2764,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
param_count += 1
|
||||
units = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count, source_memory_ids
|
||||
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count
|
||||
FROM {fq_table("memory_units")}
|
||||
{where_clause}
|
||||
ORDER BY mentioned_at DESC NULLS LAST, event_date DESC
|
||||
@@ -2777,18 +2777,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Get links, filtering to only include links between units of the selected agent
|
||||
# Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links
|
||||
unit_ids = [row["id"] for row in units]
|
||||
unit_id_set = set(unit_ids)
|
||||
|
||||
# Collect source memory IDs from observations
|
||||
source_memory_ids = []
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"]:
|
||||
source_memory_ids.extend(unit["source_memory_ids"])
|
||||
source_memory_ids = list(set(source_memory_ids)) # Deduplicate
|
||||
|
||||
# Fetch links involving both visible units AND source memories
|
||||
all_relevant_ids = unit_ids + source_memory_ids
|
||||
if all_relevant_ids:
|
||||
if unit_ids:
|
||||
links = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid))
|
||||
@@ -2799,69 +2788,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
e.canonical_name as entity_name
|
||||
FROM {fq_table("memory_links")} ml
|
||||
LEFT JOIN {fq_table("entities")} e ON ml.entity_id = e.id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[]) OR ml.to_unit_id = ANY($1::uuid[])
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[])
|
||||
ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC
|
||||
""",
|
||||
all_relevant_ids,
|
||||
unit_ids,
|
||||
)
|
||||
else:
|
||||
links = []
|
||||
|
||||
# Copy links from source memories to observations
|
||||
# Observations inherit links from their source memories via source_memory_ids
|
||||
# Build a map from source_id to observation_ids
|
||||
source_to_observations = {}
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"]:
|
||||
for source_id in unit["source_memory_ids"]:
|
||||
if source_id not in source_to_observations:
|
||||
source_to_observations[source_id] = []
|
||||
source_to_observations[source_id].append(unit["id"])
|
||||
|
||||
copied_links = []
|
||||
for link in links:
|
||||
from_id = link["from_unit_id"]
|
||||
to_id = link["to_unit_id"]
|
||||
|
||||
# Get observations that should inherit this link
|
||||
from_observations = source_to_observations.get(from_id, [])
|
||||
to_observations = source_to_observations.get(to_id, [])
|
||||
|
||||
# If from_id is a source memory, copy links to its observations
|
||||
if from_observations:
|
||||
for obs_id in from_observations:
|
||||
# Only include if the target is visible
|
||||
if to_id in unit_id_set or to_observations:
|
||||
target = to_observations[0] if to_observations and to_id not in unit_id_set else to_id
|
||||
if target in unit_id_set:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": obs_id,
|
||||
"to_unit_id": target,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
|
||||
# If to_id is a source memory, copy links to its observations
|
||||
if to_observations and from_id in unit_id_set:
|
||||
for obs_id in to_observations:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": from_id,
|
||||
"to_unit_id": obs_id,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
|
||||
# Keep only direct links between visible nodes
|
||||
direct_links = [
|
||||
link for link in links if link["from_unit_id"] in unit_id_set and link["to_unit_id"] in unit_id_set
|
||||
]
|
||||
|
||||
# Get entity information
|
||||
unit_entities = await conn.fetch(f"""
|
||||
SELECT ue.unit_id, e.canonical_name
|
||||
@@ -2879,18 +2813,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
entity_map[unit_id] = []
|
||||
entity_map[unit_id].append(entity_name)
|
||||
|
||||
# For observations, inherit entities from source memories
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"] and unit["id"] not in entity_map:
|
||||
# Collect entities from all source memories
|
||||
source_entities = []
|
||||
for source_id in unit["source_memory_ids"]:
|
||||
if source_id in entity_map:
|
||||
source_entities.extend(entity_map[source_id])
|
||||
if source_entities:
|
||||
# Deduplicate while preserving order
|
||||
entity_map[unit["id"]] = list(dict.fromkeys(source_entities))
|
||||
|
||||
# Build nodes
|
||||
nodes = []
|
||||
for row in units:
|
||||
@@ -2924,15 +2846,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# Build edges (combine direct links and copied links from sources)
|
||||
# Build edges
|
||||
edges = []
|
||||
all_links = direct_links + copied_links
|
||||
for row in all_links:
|
||||
for row in links:
|
||||
from_id = str(row["from_unit_id"])
|
||||
to_id = str(row["to_unit_id"])
|
||||
link_type = row["link_type"]
|
||||
weight = row["weight"]
|
||||
entity_name = row.get("entity_name")
|
||||
entity_name = row["entity_name"]
|
||||
|
||||
# Color by link type
|
||||
if link_type == "temporal":
|
||||
|
||||
@@ -58,7 +58,6 @@ def _normalize_tool_name(name: str) -> str:
|
||||
- 'functions.done' (OpenAI-style prefix)
|
||||
- 'call=functions.done' (some models)
|
||||
- 'call=done' (some models)
|
||||
- 'done<|channel|>commentary' (malformed special tokens appended)
|
||||
|
||||
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
|
||||
"""
|
||||
@@ -70,11 +69,6 @@ def _normalize_tool_name(name: str) -> str:
|
||||
if name.startswith("functions."):
|
||||
name = name[len("functions.") :]
|
||||
|
||||
# Handle malformed special tokens appended to tool name
|
||||
# e.g., 'done<|channel|>commentary' -> 'done'
|
||||
if "<|" in name:
|
||||
name = name.split("<|")[0]
|
||||
|
||||
return name
|
||||
|
||||
|
||||
|
||||
@@ -432,15 +432,34 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
|
||||
# FACT EXTRACTION PROMPTS
|
||||
# =============================================================================
|
||||
|
||||
# Base prompt template (shared by concise and custom modes)
|
||||
# Uses {extraction_guidelines} placeholder for mode-specific instructions
|
||||
_BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
# Concise extraction prompt (default) - selective, high-quality facts
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
|
||||
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
{extraction_guidelines}
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ONLY extract facts that are:
|
||||
✅ Personal info: names, relationships, roles, background
|
||||
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
|
||||
✅ Significant events: milestones, decisions, achievements, changes
|
||||
✅ Plans/goals: future intentions, deadlines, commitments
|
||||
✅ Expertise: skills, knowledge, certifications, experience
|
||||
✅ Important context: projects, problems, constraints
|
||||
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
|
||||
✅ Observations: descriptions of people, places, things with specific details
|
||||
|
||||
DO NOT extract:
|
||||
❌ Generic greetings: "how are you", "hello", pleasantries without substance
|
||||
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
|
||||
❌ Process chatter: "let me check", "one moment", "I'll look into it"
|
||||
❌ Repeated info: if already stated, don't extract again
|
||||
|
||||
CONSOLIDATE related statements into ONE fact when possible.
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
FACT FORMAT - BE CONCISE
|
||||
@@ -488,33 +507,7 @@ ENTITIES
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
|
||||
Always include "user" when fact is about the user.{examples}"""
|
||||
|
||||
# Concise mode guidelines
|
||||
_CONCISE_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
|
||||
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ONLY extract facts that are:
|
||||
✅ Personal info: names, relationships, roles, background
|
||||
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
|
||||
✅ Significant events: milestones, decisions, achievements, changes
|
||||
✅ Plans/goals: future intentions, deadlines, commitments
|
||||
✅ Expertise: skills, knowledge, certifications, experience
|
||||
✅ Important context: projects, problems, constraints
|
||||
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
|
||||
✅ Observations: descriptions of people, places, things with specific details
|
||||
|
||||
DO NOT extract:
|
||||
❌ Generic greetings: "how are you", "hello", pleasantries without substance
|
||||
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
|
||||
❌ Process chatter: "let me check", "one moment", "I'll look into it"
|
||||
❌ Repeated info: if already stated, don't extract again
|
||||
|
||||
CONSOLIDATE related statements into ONE fact when possible."""
|
||||
|
||||
# Concise mode examples
|
||||
_CONCISE_EXAMPLES = """
|
||||
Always include "user" when fact is about the user.
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
EXAMPLES
|
||||
@@ -540,20 +533,6 @@ QUALITY OVER QUANTITY
|
||||
|
||||
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
|
||||
|
||||
# Assembled concise prompt (backward compatible - exact same output as before)
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
|
||||
fact_types_instruction="{fact_types_instruction}",
|
||||
extraction_guidelines=_CONCISE_GUIDELINES,
|
||||
examples=_CONCISE_EXAMPLES,
|
||||
)
|
||||
|
||||
# Custom prompt uses same base but without examples
|
||||
CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
|
||||
fact_types_instruction="{fact_types_instruction}",
|
||||
extraction_guidelines="{custom_instructions}",
|
||||
examples="", # No examples for custom mode
|
||||
)
|
||||
|
||||
|
||||
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
|
||||
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
|
||||
@@ -701,12 +680,6 @@ async def _extract_facts_from_chunk(
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from openai import BadRequestError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
|
||||
|
||||
# Determine which fact types to extract based on the flag
|
||||
@@ -725,27 +698,13 @@ async def _extract_facts_from_chunk(
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Select base prompt based on extraction mode
|
||||
if extraction_mode == "custom":
|
||||
# Custom mode: inject user-provided guidelines
|
||||
if not config.retain_custom_instructions:
|
||||
logger.warning(
|
||||
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
|
||||
"Falling back to 'concise' mode."
|
||||
)
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
else:
|
||||
base_prompt = CUSTOM_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(
|
||||
fact_types_instruction=fact_types_instruction,
|
||||
custom_instructions=config.retain_custom_instructions,
|
||||
)
|
||||
elif extraction_mode == "verbose":
|
||||
if extraction_mode == "verbose":
|
||||
base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
else:
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
|
||||
# Format the prompt with fact types instruction
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
|
||||
# Build the full prompt with or without causal relationships section
|
||||
# Select appropriate response schema based on extraction mode and causal links
|
||||
@@ -758,6 +717,12 @@ async def _extract_facts_from_chunk(
|
||||
else:
|
||||
response_schema = FactExtractionResponseNoCausal
|
||||
|
||||
import logging
|
||||
|
||||
from openai import BadRequestError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Retry logic for JSON validation errors
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
|
||||
@@ -155,6 +155,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
all_seeds.extend(temporal_seeds)
|
||||
|
||||
if not all_seeds:
|
||||
logger.info("[LinkExpansion] No seeds found, returning empty results")
|
||||
return [], timings
|
||||
|
||||
seed_ids = list({s.id for s in all_seeds})
|
||||
|
||||
@@ -213,7 +213,6 @@ def main():
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_observations_async=config.retain_observations_async,
|
||||
enable_observations=config.enable_observations,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
|
||||
@@ -1897,93 +1897,3 @@ class TestMentalModelRefreshAfterConsolidation:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_endpoint_observations_inherit_links_and_entities(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""Test that graph endpoint shows links and entities for observations filtered by type.
|
||||
|
||||
When filtering graph by type=observation:
|
||||
- Observations should inherit links from their source memories
|
||||
- Observations should show entities inherited from source memories
|
||||
- Even when source memories are not visible, their links should be copied to observations
|
||||
"""
|
||||
bank_id = f"test-graph-obs-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Retain content that will create world facts with shared entities
|
||||
# This should create facts that are linked by shared entities
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice works at Google as a software engineer.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Bob also works at Google in the sales department.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Wait for consolidation to create observations
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get graph data filtered by observation type only
|
||||
graph_data = await memory.get_graph_data(
|
||||
bank_id=bank_id,
|
||||
fact_type="observation",
|
||||
limit=1000,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should have observations
|
||||
assert graph_data["total_units"] > 0, "Should have observations"
|
||||
assert len(graph_data["nodes"]) > 0, "Should have observation nodes"
|
||||
|
||||
# Verify all nodes are observations
|
||||
for row in graph_data["table_rows"]:
|
||||
assert row["fact_type"] == "observation", f"All nodes should be observations, got {row['fact_type']}"
|
||||
|
||||
# Should have edges (inherited from source memories)
|
||||
# Even though we're only showing observations, they should inherit links from their sources
|
||||
assert len(graph_data["edges"]) > 0, (
|
||||
"Observations should have edges inherited from source memories. "
|
||||
f"Found {len(graph_data['edges'])} edges"
|
||||
)
|
||||
|
||||
# Should have entities (inherited from source memories)
|
||||
observations_with_entities = [
|
||||
row for row in graph_data["table_rows"] if row["entities"] and row["entities"] != "None"
|
||||
]
|
||||
assert len(observations_with_entities) > 0, (
|
||||
"Observations should inherit entities from source memories. "
|
||||
f"Found {len(observations_with_entities)} observations with entities"
|
||||
)
|
||||
|
||||
# Verify entities contain expected values
|
||||
all_entities = " ".join([row["entities"] for row in graph_data["table_rows"]])
|
||||
assert "Alice" in all_entities or "Bob" in all_entities or "Google" in all_entities, (
|
||||
f"Expected to find Alice, Bob, or Google in entities, got: {all_entities}"
|
||||
)
|
||||
|
||||
# Verify edge types are valid
|
||||
valid_link_types = {"semantic", "temporal", "entity"}
|
||||
for edge in graph_data["edges"]:
|
||||
link_type = edge["data"]["linkType"]
|
||||
assert link_type in valid_link_types, f"Invalid link type: {link_type}"
|
||||
|
||||
# Verify all edges connect visible observation nodes
|
||||
visible_node_ids = {row["id"] for row in graph_data["table_rows"]}
|
||||
for edge in graph_data["edges"]:
|
||||
source_id = edge["data"]["source"]
|
||||
target_id = edge["data"]["target"]
|
||||
assert source_id in visible_node_ids, f"Edge source {source_id[:8]} not in visible nodes"
|
||||
assert target_id in visible_node_ids, f"Edge target {target_id[:8]} not in visible nodes"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -163,12 +163,6 @@ class TestToolNameNormalization:
|
||||
assert _normalize_tool_name("call=functions.recall") == "recall"
|
||||
assert _normalize_tool_name("call=functions.search_observations") == "search_observations"
|
||||
|
||||
def test_normalize_special_token_suffix(self):
|
||||
"""Tool names with malformed special tokens should be normalized."""
|
||||
assert _normalize_tool_name("done<|channel|>commentary") == "done"
|
||||
assert _normalize_tool_name("recall<|endoftext|>") == "recall"
|
||||
assert _normalize_tool_name("search_observations<|im_end|>extra") == "search_observations"
|
||||
|
||||
def test_is_done_tool(self):
|
||||
"""Test _is_done_tool helper."""
|
||||
# Standard
|
||||
@@ -180,14 +174,9 @@ class TestToolNameNormalization:
|
||||
assert _is_done_tool("call=done") is True
|
||||
assert _is_done_tool("call=functions.done") is True
|
||||
|
||||
# With malformed special tokens
|
||||
assert _is_done_tool("done<|channel|>commentary") is True
|
||||
assert _is_done_tool("done<|endoftext|>") is True
|
||||
|
||||
# Not done
|
||||
assert _is_done_tool("functions.recall") is False
|
||||
assert _is_done_tool("call=functions.recall") is False
|
||||
assert _is_done_tool("recall<|channel|>done") is False
|
||||
|
||||
|
||||
class TestReflectAgentMocked:
|
||||
|
||||
@@ -2082,117 +2082,3 @@ def test_recall_result_model_empty_construction():
|
||||
assert result.chunks == {}, "Should have empty chunks"
|
||||
|
||||
logger.info("✓ RecallResult empty construction works correctly")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_extraction_mode():
|
||||
"""
|
||||
Test that custom extraction mode uses custom guidelines from env variable.
|
||||
|
||||
This test verifies that when HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom and
|
||||
HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS is set, the fact extraction uses the
|
||||
custom guidelines while keeping structural parts intact.
|
||||
"""
|
||||
import os
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
# Save original env vars
|
||||
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
|
||||
original_instructions = os.getenv("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS")
|
||||
|
||||
try:
|
||||
# Set custom extraction mode with challenging language-specific guidelines
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "custom"
|
||||
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = """ONLY extract facts that are in ITALIAN language.
|
||||
|
||||
DO NOT extract:
|
||||
❌ Facts in English
|
||||
❌ Facts in any other language besides Italian
|
||||
|
||||
If the text contains both Italian and English content, extract ONLY the Italian facts."""
|
||||
|
||||
# Clear config cache to pick up new env vars
|
||||
clear_config_cache()
|
||||
|
||||
# Test content with BOTH Italian (should extract) and English (should NOT extract) facts
|
||||
# This is a much harder test than filtering greetings
|
||||
text = """
|
||||
The team discussed the new architecture. We will use microservices.
|
||||
|
||||
Il database PostgreSQL ha ridotto la latenza delle query del 60%.
|
||||
Alice ha suggerito di usare il connection pooling per migliorare le prestazioni.
|
||||
|
||||
Bob mentioned that the API endpoint is ready for testing.
|
||||
The deployment pipeline has been updated to use Kubernetes.
|
||||
|
||||
Marco ha completato la revisione del codice e ha approvato le modifiche.
|
||||
Il sistema di autenticazione è stato migrato a OAuth 2.0.
|
||||
"""
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
context="team meeting notes",
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
logger.info(f"\nExtracted {len(facts)} facts with custom mode (Italian only):")
|
||||
for i, fact in enumerate(facts):
|
||||
logger.info(f" {i+1}. {fact.fact}")
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one Italian fact"
|
||||
|
||||
# All facts text
|
||||
all_facts_text = " ".join([f.fact for f in facts])
|
||||
|
||||
# Should HAVE Italian content
|
||||
italian_keywords = ["postgresql", "latenza", "query", "alice", "connection pooling", "prestazioni",
|
||||
"marco", "revisione", "codice", "autenticazione", "oauth"]
|
||||
has_italian = any(keyword in all_facts_text.lower() for keyword in italian_keywords)
|
||||
assert has_italian, f"Should extract Italian facts. Got: {all_facts_text}"
|
||||
|
||||
# Should NOT have English-only content
|
||||
# These are facts that appear ONLY in English sections
|
||||
english_only_keywords = ["microservices", "bob", "api endpoint", "testing", "deployment pipeline", "kubernetes"]
|
||||
|
||||
# Check if facts contain English-only content (this would be wrong)
|
||||
facts_lower = all_facts_text.lower()
|
||||
found_english_only = [kw for kw in english_only_keywords if kw in facts_lower]
|
||||
|
||||
if found_english_only:
|
||||
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
|
||||
logger.warning(f" Facts: {all_facts_text}")
|
||||
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
|
||||
# Log but don't fail - LLM behavior can vary
|
||||
else:
|
||||
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
|
||||
|
||||
# At least verify we have some Italian indicators
|
||||
italian_indicators = ["latenza", "prestazioni", "revisione", "codice", "autenticazione"]
|
||||
italian_count = sum(1 for ind in italian_indicators if ind in facts_lower)
|
||||
|
||||
assert italian_count >= 1, \
|
||||
f"Should extract facts with Italian words. Found {italian_count} Italian indicators in: {all_facts_text}"
|
||||
|
||||
logger.info("✓ Custom extraction mode works with language-specific guidelines")
|
||||
logger.info(f"✓ Extracted {len(facts)} Italian facts, found {italian_count} Italian indicators")
|
||||
|
||||
finally:
|
||||
# Restore original env vars
|
||||
if original_mode is not None:
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
|
||||
else:
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
|
||||
|
||||
if original_instructions is not None:
|
||||
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = original_instructions
|
||||
else:
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS", None)
|
||||
|
||||
# Clear cache again to restore original config
|
||||
clear_config_cache()
|
||||
|
||||
@@ -319,8 +319,7 @@ Controls the retain (memory ingestion) pipeline.
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
|
||||
| `HINDSIGHT_API_RETAIN_CHUNK_SIZE` | Max characters per chunk for fact extraction. Larger chunks extract fewer LLM calls but may lose context. | `3000` |
|
||||
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise`, `verbose`, or `custom` | `concise` |
|
||||
| `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` | Custom extraction guidelines (only used when mode is `custom`) | - |
|
||||
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise` (selective, fewer high-quality facts) or `verbose` (detailed, more facts) | `concise` |
|
||||
| `HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS` | Extract causal relationships between facts | `true` |
|
||||
|
||||
#### Extraction Modes
|
||||
@@ -331,31 +330,6 @@ The extraction mode controls how aggressively facts are extracted from content:
|
||||
|
||||
- **`verbose`**: Detailed extraction that captures every piece of information with maximum verbosity. Produces more facts with extensive detail but slower performance and higher token usage.
|
||||
|
||||
- **`custom`**: Inject your own extraction guidelines while keeping the structural parts of the prompt (output format, coreference resolution, temporal handling, etc.) intact. Useful for A/B testing different extraction strategies or domain-specific customization.
|
||||
|
||||
**Example: Custom Extraction Mode**
|
||||
|
||||
```bash
|
||||
# Set mode to custom
|
||||
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
|
||||
|
||||
# Define custom guidelines (multi-line is fine)
|
||||
export HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS="ONLY extract facts that are:
|
||||
✅ Technical decisions and their rationale
|
||||
✅ Architecture patterns and design choices
|
||||
✅ Performance metrics and benchmarks
|
||||
✅ Code reviews and feedback
|
||||
|
||||
DO NOT extract:
|
||||
❌ Generic greetings or pleasantries
|
||||
❌ Process chatter (\"let me check\", \"one moment\")
|
||||
❌ Repeated information already captured
|
||||
|
||||
CONSOLIDATE related technical discussions into ONE fact when possible.
|
||||
|
||||
Ask yourself: 'Would this technical context be useful in 6 months?' If no, skip it."
|
||||
```
|
||||
|
||||
### Observations (Experimental)
|
||||
|
||||
Observations are consolidated knowledge synthesized from facts.
|
||||
|
||||
Reference in New Issue
Block a user