hindsight-api 0.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. hindsight_api/__init__.py +38 -0
  2. hindsight_api/api/__init__.py +105 -0
  3. hindsight_api/api/http.py +1872 -0
  4. hindsight_api/api/mcp.py +157 -0
  5. hindsight_api/engine/__init__.py +47 -0
  6. hindsight_api/engine/cross_encoder.py +97 -0
  7. hindsight_api/engine/db_utils.py +93 -0
  8. hindsight_api/engine/embeddings.py +113 -0
  9. hindsight_api/engine/entity_resolver.py +575 -0
  10. hindsight_api/engine/llm_wrapper.py +269 -0
  11. hindsight_api/engine/memory_engine.py +3095 -0
  12. hindsight_api/engine/query_analyzer.py +519 -0
  13. hindsight_api/engine/response_models.py +222 -0
  14. hindsight_api/engine/retain/__init__.py +50 -0
  15. hindsight_api/engine/retain/bank_utils.py +423 -0
  16. hindsight_api/engine/retain/chunk_storage.py +82 -0
  17. hindsight_api/engine/retain/deduplication.py +104 -0
  18. hindsight_api/engine/retain/embedding_processing.py +62 -0
  19. hindsight_api/engine/retain/embedding_utils.py +54 -0
  20. hindsight_api/engine/retain/entity_processing.py +90 -0
  21. hindsight_api/engine/retain/fact_extraction.py +1027 -0
  22. hindsight_api/engine/retain/fact_storage.py +176 -0
  23. hindsight_api/engine/retain/link_creation.py +121 -0
  24. hindsight_api/engine/retain/link_utils.py +651 -0
  25. hindsight_api/engine/retain/orchestrator.py +405 -0
  26. hindsight_api/engine/retain/types.py +206 -0
  27. hindsight_api/engine/search/__init__.py +15 -0
  28. hindsight_api/engine/search/fusion.py +122 -0
  29. hindsight_api/engine/search/observation_utils.py +132 -0
  30. hindsight_api/engine/search/reranking.py +103 -0
  31. hindsight_api/engine/search/retrieval.py +503 -0
  32. hindsight_api/engine/search/scoring.py +161 -0
  33. hindsight_api/engine/search/temporal_extraction.py +64 -0
  34. hindsight_api/engine/search/think_utils.py +255 -0
  35. hindsight_api/engine/search/trace.py +215 -0
  36. hindsight_api/engine/search/tracer.py +447 -0
  37. hindsight_api/engine/search/types.py +160 -0
  38. hindsight_api/engine/task_backend.py +223 -0
  39. hindsight_api/engine/utils.py +203 -0
  40. hindsight_api/metrics.py +227 -0
  41. hindsight_api/migrations.py +163 -0
  42. hindsight_api/models.py +309 -0
  43. hindsight_api/pg0.py +425 -0
  44. hindsight_api/web/__init__.py +12 -0
  45. hindsight_api/web/server.py +143 -0
  46. hindsight_api-0.0.13.dist-info/METADATA +41 -0
  47. hindsight_api-0.0.13.dist-info/RECORD +48 -0
  48. hindsight_api-0.0.13.dist-info/WHEEL +4 -0
@@ -0,0 +1,64 @@
1
+ """
2
+ Temporal extraction for time-aware search queries.
3
+
4
+ Handles natural language temporal expressions using transformer-based query analysis.
5
+ """
6
+
7
+ from typing import Optional, Tuple
8
+ from datetime import datetime
9
+ import logging
10
+ from hindsight_api.engine.query_analyzer import QueryAnalyzer, DateparserQueryAnalyzer
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Global default analyzer instance
15
+ # Can be overridden by passing a custom analyzer to extract_temporal_constraint
16
+ _default_analyzer: Optional[QueryAnalyzer] = None
17
+
18
+
19
+ def get_default_analyzer() -> QueryAnalyzer:
20
+ """
21
+ Get or create the default query analyzer.
22
+
23
+ Uses lazy initialization to avoid loading at import time.
24
+
25
+ Returns:
26
+ Default DateparserQueryAnalyzer instance
27
+ """
28
+ global _default_analyzer
29
+ if _default_analyzer is None:
30
+ _default_analyzer = DateparserQueryAnalyzer()
31
+ return _default_analyzer
32
+
33
+
34
+ def extract_temporal_constraint(
35
+ query: str,
36
+ reference_date: Optional[datetime] = None,
37
+ analyzer: Optional[QueryAnalyzer] = None,
38
+ ) -> Optional[Tuple[datetime, datetime]]:
39
+ """
40
+ Extract temporal constraint from query.
41
+
42
+ Returns (start_date, end_date) tuple if temporal constraint found, else None.
43
+
44
+ Args:
45
+ query: Search query
46
+ reference_date: Reference date for relative terms (defaults to now)
47
+ analyzer: Custom query analyzer (defaults to DateparserQueryAnalyzer)
48
+
49
+ Returns:
50
+ (start_date, end_date) tuple or None
51
+ """
52
+ if analyzer is None:
53
+ analyzer = get_default_analyzer()
54
+
55
+ analysis = analyzer.analyze(query, reference_date)
56
+
57
+ if analysis.temporal_constraint:
58
+ result = (
59
+ analysis.temporal_constraint.start_date,
60
+ analysis.temporal_constraint.end_date
61
+ )
62
+ return result
63
+
64
+ return None
@@ -0,0 +1,255 @@
1
+ """
2
+ Think operation utilities for formulating answers based on agent and world facts.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from typing import Dict, List, Any
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ..response_models import ReflectResult, MemoryFact, PersonalityTraits
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Opinion(BaseModel):
18
+ """An opinion formed by the bank."""
19
+ opinion: str = Field(description="The opinion or perspective with reasoning included")
20
+ confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
21
+
22
+
23
+ class OpinionExtractionResponse(BaseModel):
24
+ """Response containing extracted opinions."""
25
+ opinions: List[Opinion] = Field(
26
+ default_factory=list,
27
+ description="List of opinions formed with their supporting reasons and confidence scores"
28
+ )
29
+
30
+
31
+ def describe_trait(name: str, value: float) -> str:
32
+ """Convert trait value to descriptive text."""
33
+ if value >= 0.8:
34
+ return f"very high {name}"
35
+ elif value >= 0.6:
36
+ return f"high {name}"
37
+ elif value >= 0.4:
38
+ return f"moderate {name}"
39
+ elif value >= 0.2:
40
+ return f"low {name}"
41
+ else:
42
+ return f"very low {name}"
43
+
44
+
45
+ def build_personality_description(personality: PersonalityTraits) -> str:
46
+ """Build a personality description string from personality traits."""
47
+ return f"""Your personality traits:
48
+ - {describe_trait('openness to new ideas', personality.openness)}
49
+ - {describe_trait('conscientiousness and organization', personality.conscientiousness)}
50
+ - {describe_trait('extraversion and sociability', personality.extraversion)}
51
+ - {describe_trait('agreeableness and cooperation', personality.agreeableness)}
52
+ - {describe_trait('emotional sensitivity', personality.neuroticism)}
53
+
54
+ Personality influence strength: {int(personality.bias_strength * 100)}% (how much your personality shapes your opinions)"""
55
+
56
+
57
+ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
58
+ """Format facts as JSON for LLM prompt."""
59
+ import json
60
+
61
+ if not facts:
62
+ return "[]"
63
+ formatted = []
64
+ for fact in facts:
65
+ fact_obj = {
66
+ "text": fact.text
67
+ }
68
+
69
+ # Add context if available
70
+ if fact.context:
71
+ fact_obj["context"] = fact.context
72
+
73
+ # Add occurred_start if available (when the fact occurred)
74
+ if fact.occurred_start:
75
+ occurred_start = fact.occurred_start
76
+ if isinstance(occurred_start, str):
77
+ fact_obj["occurred_start"] = occurred_start
78
+ elif isinstance(occurred_start, datetime):
79
+ fact_obj["occurred_start"] = occurred_start.strftime('%Y-%m-%d %H:%M:%S')
80
+
81
+ # Add activation if available
82
+ if fact.activation is not None:
83
+ fact_obj["score"] = fact.activation
84
+
85
+ formatted.append(fact_obj)
86
+
87
+ return json.dumps(formatted, indent=2)
88
+
89
+
90
+ def build_think_prompt(
91
+ agent_facts_text: str,
92
+ world_facts_text: str,
93
+ opinion_facts_text: str,
94
+ query: str,
95
+ name: str,
96
+ personality: PersonalityTraits,
97
+ background: str,
98
+ context: str = None,
99
+ ) -> str:
100
+ """Build the think prompt for the LLM."""
101
+ personality_desc = build_personality_description(personality)
102
+
103
+ name_section = f"""
104
+
105
+ Your name: {name}
106
+ """
107
+
108
+ background_section = ""
109
+ if background:
110
+ background_section = f"""
111
+
112
+ Your background:
113
+ {background}
114
+ """
115
+
116
+ context_section = ""
117
+ if context:
118
+ context_section = f"""
119
+ ADDITIONAL CONTEXT:
120
+ {context}
121
+
122
+ """
123
+
124
+ return f"""Here's what I know and have experienced:
125
+
126
+ MY IDENTITY & EXPERIENCES:
127
+ {agent_facts_text}
128
+
129
+ WHAT I KNOW ABOUT THE WORLD:
130
+ {world_facts_text}
131
+
132
+ MY EXISTING OPINIONS & BELIEFS:
133
+ {opinion_facts_text}
134
+
135
+ {context_section}{name_section}{personality_desc}{background_section}
136
+
137
+ QUESTION: {query}
138
+
139
+ Based on everything I know, believe, and who I am (including my name, personality and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
140
+
141
+
142
+ def get_system_message(personality: PersonalityTraits) -> str:
143
+ """Get the system message for the think LLM call."""
144
+ bias_strength = personality.bias_strength
145
+ if bias_strength >= 0.7:
146
+ personality_instruction = "Your personality strongly influences your thinking. Let your traits guide how you interpret facts and form opinions. Don't be afraid to be biased based on your personality."
147
+ elif bias_strength >= 0.4:
148
+ personality_instruction = "Your personality moderately influences your thinking. Balance your personal traits with objective analysis."
149
+ else:
150
+ personality_instruction = "Your personality has minimal influence on your thinking. Focus primarily on facts while keeping your traits in mind."
151
+
152
+ return f"You are a person with your own thoughts, experiences, opinions, and personality. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {personality_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
153
+
154
+
155
+ async def extract_opinions_from_text(
156
+ llm_config,
157
+ text: str,
158
+ query: str
159
+ ) -> List[Opinion]:
160
+ """
161
+ Extract opinions with reasons and confidence from text using LLM.
162
+
163
+ Args:
164
+ llm_config: LLM configuration to use
165
+ text: Text to extract opinions from
166
+ query: The original query that prompted this response
167
+
168
+ Returns:
169
+ List of Opinion objects with text and confidence
170
+ """
171
+ extraction_prompt = f"""Extract any NEW opinions or perspectives from the answer below and rewrite them in FIRST-PERSON as if YOU are stating the opinion directly.
172
+
173
+ ORIGINAL QUESTION:
174
+ {query}
175
+
176
+ ANSWER PROVIDED:
177
+ {text}
178
+
179
+ Your task: Find opinions in the answer and rewrite them AS IF YOU ARE THE ONE SAYING THEM.
180
+
181
+ An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts.
182
+
183
+ IMPORTANT: Do NOT extract statements like:
184
+ - "I don't have enough information"
185
+ - "The facts don't contain information about X"
186
+ - "I cannot answer because..."
187
+
188
+ ONLY extract actual opinions about substantive topics.
189
+
190
+ CRITICAL FORMAT REQUIREMENTS:
191
+ 1. **ALWAYS start with first-person phrases**: "I think...", "I believe...", "In my view...", "I've come to believe...", "Previously I thought... but now..."
192
+ 2. **NEVER use third-person**: Do NOT say "The speaker thinks..." or "They believe..." - always use "I"
193
+ 3. Include the reasoning naturally within the statement
194
+ 4. Provide a confidence score (0.0 to 1.0)
195
+
196
+ CORRECT Examples (✓ FIRST-PERSON):
197
+ - "I think Alice is more reliable because she consistently delivers on time and writes clean code"
198
+ - "Previously I thought all engineers were equal, but now I feel that experience and track record really matter"
199
+ - "I believe reliability is best measured by consistent output over time"
200
+ - "I've come to believe that track records are more important than potential"
201
+
202
+ WRONG Examples (✗ THIRD-PERSON - DO NOT USE):
203
+ - "The speaker thinks Alice is more reliable"
204
+ - "They believe reliability matters"
205
+ - "It is believed that Alice is better"
206
+
207
+ If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list."""
208
+
209
+ try:
210
+ result = await llm_config.call(
211
+ messages=[
212
+ {"role": "system", "content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'."},
213
+ {"role": "user", "content": extraction_prompt}
214
+ ],
215
+ response_format=OpinionExtractionResponse,
216
+ scope="memory_extract_opinion"
217
+ )
218
+
219
+ # Format opinions with confidence score and convert to first-person
220
+ formatted_opinions = []
221
+ for op in result.opinions:
222
+ # Convert third-person to first-person if needed
223
+ opinion_text = op.opinion
224
+
225
+ # Replace common third-person patterns with first-person
226
+ def singularize_verb(verb):
227
+ if verb.endswith('es'):
228
+ return verb[:-1] # believes -> believe
229
+ elif verb.endswith('s'):
230
+ return verb[:-1] # thinks -> think
231
+ return verb
232
+
233
+ # Pattern: "The speaker/user [verb]..." -> "I [verb]..."
234
+ match = re.match(r'^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$', opinion_text, re.IGNORECASE)
235
+ if match:
236
+ verb = singularize_verb(match.group(2))
237
+ that_part = match.group(3) or "" # Keep " that" if present
238
+ rest = match.group(4)
239
+ opinion_text = f"I {verb}{that_part}{rest}"
240
+
241
+ # If still doesn't start with first-person, prepend "I believe that "
242
+ first_person_starters = ["I think", "I believe", "I feel", "In my view", "I've come to believe", "Previously I"]
243
+ if not any(opinion_text.startswith(starter) for starter in first_person_starters):
244
+ opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
245
+
246
+ formatted_opinions.append(Opinion(
247
+ opinion=opinion_text,
248
+ confidence=op.confidence
249
+ ))
250
+
251
+ return formatted_opinions
252
+
253
+ except Exception as e:
254
+ logger.warning(f"Failed to extract opinions: {str(e)}")
255
+ return []
@@ -0,0 +1,215 @@
1
+ """
2
+ Search trace models for debugging and visualization.
3
+
4
+ These Pydantic models define the structure of search traces, capturing
5
+ every step of the spreading activation search process for analysis.
6
+ """
7
+ from datetime import datetime
8
+ from typing import List, Optional, Dict, Any, Literal
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class QueryInfo(BaseModel):
13
+ """Information about the search query."""
14
+ query_text: str = Field(description="Original query text")
15
+ query_embedding: List[float] = Field(description="Generated query embedding vector")
16
+ timestamp: datetime = Field(description="When the query was executed")
17
+ budget: int = Field(description="Maximum nodes to explore")
18
+ max_tokens: int = Field(description="Maximum tokens to return in results")
19
+
20
+
21
+ class EntryPoint(BaseModel):
22
+ """An entry point node selected for search."""
23
+ node_id: str = Field(description="Memory unit ID")
24
+ text: str = Field(description="Memory unit text content")
25
+ similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0)
26
+ rank: int = Field(description="Rank among entry points (1-based)")
27
+
28
+
29
+ class WeightComponents(BaseModel):
30
+ """Breakdown of weight calculation components."""
31
+ activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0)
32
+ semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0)
33
+ recency: float = Field(description="Recency weight", ge=0.0, le=1.0)
34
+ frequency: float = Field(description="Normalized frequency weight", ge=0.0, le=1.0)
35
+ final_weight: float = Field(description="Combined final weight")
36
+
37
+ # Weight formula components (for transparency)
38
+ activation_contribution: float = Field(description="0.3 * activation")
39
+ semantic_contribution: float = Field(description="0.3 * semantic_similarity")
40
+ recency_contribution: float = Field(description="0.25 * recency")
41
+ frequency_contribution: float = Field(description="0.15 * frequency")
42
+
43
+
44
+ class LinkInfo(BaseModel):
45
+ """Information about a link to a neighbor."""
46
+ to_node_id: str = Field(description="Target node ID")
47
+ link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link")
48
+ link_weight: float = Field(description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0)
49
+ entity_id: Optional[str] = Field(default=None, description="Entity ID if link_type is 'entity'")
50
+ new_activation: Optional[float] = Field(default=None, description="Activation that would be passed to neighbor (None for supplementary links)")
51
+ followed: bool = Field(description="Whether this link was followed (or pruned)")
52
+ prune_reason: Optional[str] = Field(default=None, description="Why link was not followed (if not followed)")
53
+ is_supplementary: bool = Field(default=False, description="Whether this is a supplementary link (multiple connections to same node)")
54
+
55
+
56
+ class NodeVisit(BaseModel):
57
+ """Information about visiting a node during search."""
58
+ step: int = Field(description="Step number in search (1-based)")
59
+ node_id: str = Field(description="Memory unit ID")
60
+ text: str = Field(description="Memory unit text content")
61
+ context: str = Field(description="Memory unit context")
62
+ event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
63
+ access_count: int = Field(description="Number of times accessed before this search")
64
+
65
+ # How this node was reached
66
+ is_entry_point: bool = Field(description="Whether this is an entry point")
67
+ parent_node_id: Optional[str] = Field(default=None, description="Node that led to this one")
68
+ link_type: Optional[Literal["temporal", "semantic", "entity"]] = Field(default=None, description="Type of link from parent")
69
+ link_weight: Optional[float] = Field(default=None, description="Weight of link from parent")
70
+
71
+ # Weights
72
+ weights: WeightComponents = Field(description="Weight calculation breakdown")
73
+
74
+ # Neighbors discovered from this node
75
+ neighbors_explored: List[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
76
+
77
+ # Ranking
78
+ final_rank: Optional[int] = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
79
+
80
+
81
+ class PruningDecision(BaseModel):
82
+ """Records when a node was considered but not visited."""
83
+ node_id: str = Field(description="Node that was pruned")
84
+ reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(description="Why it was pruned")
85
+ activation: float = Field(description="Activation value when pruned")
86
+ would_have_been_step: int = Field(description="What step it would have been if visited")
87
+
88
+
89
+ class SearchPhaseMetrics(BaseModel):
90
+ """Performance metrics for a search phase."""
91
+ phase_name: str = Field(description="Name of the phase")
92
+ duration_seconds: float = Field(description="Time taken in seconds")
93
+ details: Dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
94
+
95
+
96
+ class RetrievalResult(BaseModel):
97
+ """A single result from a retrieval method."""
98
+ rank: int = Field(description="Rank in this retrieval method (1-based)")
99
+ node_id: str = Field(description="Memory unit ID")
100
+ text: str = Field(description="Memory unit text content")
101
+ context: str = Field(default="", description="Memory unit context")
102
+ event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
103
+ fact_type: Optional[str] = Field(default=None, description="Fact type (world, bank, opinion)")
104
+ score: float = Field(description="Score from this retrieval method")
105
+ score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
106
+
107
+
108
+ class RetrievalMethodResults(BaseModel):
109
+ """Results from a single retrieval method."""
110
+ method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
111
+ results: List[RetrievalResult] = Field(description="Retrieved results with ranks")
112
+ duration_seconds: float = Field(description="Time taken for this retrieval")
113
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
114
+
115
+
116
+ class RRFMergeResult(BaseModel):
117
+ """A result after RRF merging."""
118
+ node_id: str = Field(description="Memory unit ID")
119
+ text: str = Field(description="Memory unit text content")
120
+ rrf_score: float = Field(description="Reciprocal Rank Fusion score")
121
+ source_ranks: Dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
122
+ final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)")
123
+
124
+
125
+ class RerankedResult(BaseModel):
126
+ """A result after reranking."""
127
+ node_id: str = Field(description="Memory unit ID")
128
+ text: str = Field(description="Memory unit text content")
129
+ rerank_score: float = Field(description="Final reranking score")
130
+ rerank_rank: int = Field(description="Rank after reranking (1-based)")
131
+ rrf_rank: int = Field(description="Original RRF rank before reranking")
132
+ rank_change: int = Field(description="Change in rank (positive = moved up)")
133
+ score_components: Dict[str, float] = Field(default_factory=dict, description="Score breakdown")
134
+
135
+
136
+ class SearchSummary(BaseModel):
137
+ """Summary statistics about the search."""
138
+ total_nodes_visited: int = Field(description="Total nodes visited")
139
+ total_nodes_pruned: int = Field(description="Total nodes pruned")
140
+ entry_points_found: int = Field(description="Number of entry points")
141
+ budget_used: int = Field(description="How much budget was used")
142
+ budget_remaining: int = Field(description="How much budget remained")
143
+ total_duration_seconds: float = Field(description="Total search duration")
144
+ results_returned: int = Field(description="Number of results returned")
145
+
146
+ # Link statistics
147
+ temporal_links_followed: int = Field(default=0, description="Temporal links followed")
148
+ semantic_links_followed: int = Field(default=0, description="Semantic links followed")
149
+ entity_links_followed: int = Field(default=0, description="Entity links followed")
150
+
151
+ # Phase timings
152
+ phase_metrics: List[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
153
+
154
+
155
+ class SearchTrace(BaseModel):
156
+ """Complete trace of a search operation."""
157
+ query: QueryInfo = Field(description="Query information")
158
+
159
+ # New 4-way retrieval architecture
160
+ retrieval_results: List[RetrievalMethodResults] = Field(default_factory=list, description="Results from each retrieval method")
161
+ rrf_merged: List[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
162
+ reranked: List[RerankedResult] = Field(default_factory=list, description="Results after reranking")
163
+
164
+ # Legacy fields (kept for backward compatibility with graph/temporal visualizations)
165
+ entry_points: List[EntryPoint] = Field(default_factory=list, description="Entry points selected for search (legacy)")
166
+ visits: List[NodeVisit] = Field(default_factory=list, description="All nodes visited during search (legacy, for graph viz)")
167
+ pruned: List[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
168
+
169
+ summary: SearchSummary = Field(description="Summary statistics")
170
+
171
+ # Final results (for comparison with visits)
172
+ final_results: List[Dict[str, Any]] = Field(description="Final ranked results returned to user")
173
+
174
+ model_config = {
175
+ "json_encoders": {
176
+ datetime: lambda v: v.isoformat()
177
+ }
178
+ }
179
+
180
+ def to_json(self, **kwargs) -> str:
181
+ """Export trace as JSON string."""
182
+ return self.model_dump_json(indent=2, **kwargs)
183
+
184
+ def to_dict(self) -> dict:
185
+ """Export trace as dictionary."""
186
+ return self.model_dump()
187
+
188
+ def get_visit_by_node_id(self, node_id: str) -> Optional[NodeVisit]:
189
+ """Find a visit by node ID."""
190
+ for visit in self.visits:
191
+ if visit.node_id == node_id:
192
+ return visit
193
+ return None
194
+
195
+ def get_search_path_to_node(self, node_id: str) -> List[NodeVisit]:
196
+ """Get the path from entry point to a specific node."""
197
+ path = []
198
+ current_visit = self.get_visit_by_node_id(node_id)
199
+
200
+ while current_visit:
201
+ path.insert(0, current_visit)
202
+ if current_visit.parent_node_id:
203
+ current_visit = self.get_visit_by_node_id(current_visit.parent_node_id)
204
+ else:
205
+ break
206
+
207
+ return path
208
+
209
+ def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> List[NodeVisit]:
210
+ """Get all nodes reached via a specific link type."""
211
+ return [v for v in self.visits if v.link_type == link_type]
212
+
213
+ def get_entry_point_nodes(self) -> List[NodeVisit]:
214
+ """Get all entry point visits."""
215
+ return [v for v in self.visits if v.is_entry_point]