diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94c574a9..b7a6d38b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,8 +202,6 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max package-helm-chart: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ceebb21b..c59d9a16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,8 +125,6 @@ jobs: file: docker/standalone/Dockerfile target: ${{ matrix.target }} push: false - cache-from: type=gha - cache-to: type=gha,mode=max test-api: runs-on: ubuntu-latest diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index e1bb27a3..0a24d440 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -1084,7 +1084,8 @@ class MemoryEngine: temporal_results = [] aggregated_timings = {"semantic": 0.0, "bm25": 0.0, "graph": 0.0, "temporal": 0.0} - for idx, (ft_semantic, ft_bm25, ft_graph, ft_temporal, ft_timings) in enumerate(all_retrievals): + detected_temporal_constraint = None + for idx, (ft_semantic, ft_bm25, ft_graph, ft_temporal, ft_timings, ft_temporal_constraint) in enumerate(all_retrievals): # Log fact types in this retrieval batch ft_name = fact_type[idx] if idx < len(fact_type) else "unknown" logger.debug(f"[SEARCH {search_id}] Fact type '{ft_name}': semantic={len(ft_semantic)}, bm25={len(ft_bm25)}, graph={len(ft_graph)}, temporal={len(ft_temporal) if ft_temporal else 0}") @@ -1097,6 +1098,9 @@ class MemoryEngine: # Track max timing for each method (since they run in parallel across fact types) for method, duration in ft_timings.items(): aggregated_timings[method] = max(aggregated_timings[method], duration) + # Capture temporal constraint (same across all fact types) + if ft_temporal_constraint: + detected_temporal_constraint = ft_temporal_constraint # If no temporal results from any fact type, set to None if not temporal_results: @@ -1120,9 +1124,13 @@ class MemoryEngine: f"bm25={len(bm25_results)}({aggregated_timings['bm25']:.3f}s)", f"graph={len(graph_results)}({aggregated_timings['graph']:.3f}s)" ] - if temporal_results: - timing_parts.append(f"temporal={len(temporal_results)}({aggregated_timings['temporal']:.3f}s)") - log_buffer.append(f" [2] {total_retrievals}-way retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {step_duration:.3f}s") + temporal_info = "" + if detected_temporal_constraint: + start_dt, end_dt = detected_temporal_constraint + temporal_count = len(temporal_results) if temporal_results else 0 + timing_parts.append(f"temporal={temporal_count}({aggregated_timings['temporal']:.3f}s)") + temporal_info = f" | temporal_range={start_dt.strftime('%Y-%m-%d')} to {end_dt.strftime('%Y-%m-%d')}" + log_buffer.append(f" [2] {total_retrievals}-way retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {step_duration:.3f}s{temporal_info}") # Record retrieval results for tracer (convert typed results to old format) if tracer: diff --git a/hindsight-api/hindsight_api/engine/query_analyzer.py b/hindsight-api/hindsight_api/engine/query_analyzer.py index 8651817d..8ee2f4b0 100644 --- a/hindsight-api/hindsight_api/engine/query_analyzer.py +++ b/hindsight-api/hindsight_api/engine/query_analyzer.py @@ -184,6 +184,34 @@ class DateparserQueryAnalyzer(QueryAnalyzer): if re.search(r'\b(today|hoy|oggi|aujourd\'?hui|heute)\b', query, re.IGNORECASE): return constraint(reference_date, reference_date) + # "a couple of days ago" / "a few days ago" patterns + # These are imprecise so we create a range + if re.search(r'\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b', query, re.IGNORECASE): + # "a couple of days" = approximately 2 days, give range of 1-3 days + return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1)) + + if re.search(r'\b(a\s+)?few\s+days?\s+ago\b', query, re.IGNORECASE): + # "a few days" = approximately 3-4 days, give range of 2-5 days + return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2)) + + # "a couple of weeks ago" / "a few weeks ago" patterns + if re.search(r'\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b', query, re.IGNORECASE): + # "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks + return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1)) + + if re.search(r'\b(a\s+)?few\s+weeks?\s+ago\b', query, re.IGNORECASE): + # "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks + return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2)) + + # "a couple of months ago" / "a few months ago" patterns + if re.search(r'\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b', query, re.IGNORECASE): + # "a couple of months" = approximately 2 months, give range of 1-3 months + return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30)) + + if re.search(r'\b(a\s+)?few\s+months?\s+ago\b', query, re.IGNORECASE): + # "a few months" = approximately 3-4 months, give range of 2-5 months + return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60)) + # Last week patterns (English, Spanish, Italian, French, German) if re.search(r'\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b', query, re.IGNORECASE): start = reference_date - timedelta(days=reference_date.weekday() + 7) diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py index 5169fab2..c18ed5e1 100644 --- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py +++ b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py @@ -330,7 +330,9 @@ FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY For EACH fact, CAPTURE ALL DETAILS - NEVER SUMMARIZE OR OMIT: 1. **what**: WHAT happened - COMPLETE description with ALL specifics (objects, actions, quantities, details) -2. **when**: WHEN it happened - ALWAYS include temporal info (dates, times, durations, relative times) +2. **when**: WHEN it happened - ALWAYS include temporal info with DAY OF WEEK (e.g., "Monday, June 10, 2024") + - Always include the day name: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday + - Format: "day_name, month day, year" (e.g., "Saturday, June 9, 2024") 3. **where**: WHERE it happened or is about - SPECIFIC locations, places, areas, regions (if applicable) 4. **who**: WHO is involved - ALL people/entities with FULL relationships and background 5. **why**: WHY it matters - ALL emotions, preferences, motivations, significance, nuance @@ -350,7 +352,7 @@ Example input: "I went to my college roommate's wedding last June. Emily finally CORRECT output: - what: "Emily got married to Sarah at a rooftop garden ceremony" -- when: "in June 2024, after dating for 5 years" +- when: "Saturday, June 8, 2024, after dating for 5 years" - where: "downtown San Francisco, at a rooftop garden venue" - who: "Emily (user's college roommate), Sarah (Emily's partner of 5 years)" - why: "User found it romantic and beautiful, dreams of similar outdoor ceremony" @@ -366,7 +368,8 @@ TEMPORAL HANDLING ══════════════════════════════════════════════════════════════════════════ For EVENTS (fact_kind="event"): -- Convert relative dates → absolute: "yesterday" on March 15 → "March 14, 2024" +- Convert relative dates → absolute WITH DAY OF WEEK: "yesterday" on Saturday March 15 → "Friday, March 14, 2024" +- Always include the day name (Monday, Tuesday, etc.) in the 'when' field - Set occurred_start/occurred_end to WHEN IT HAPPENED (not when mentioned) For CONVERSATIONS (fact_kind="conversation"): @@ -468,10 +471,12 @@ WHAT TO EXTRACT vs SKIP last_error = None # Build user message with metadata and chunk content in a clear format + # Format event_date with day of week for better temporal reasoning + event_date_formatted = event_date.strftime('%A, %B %d, %Y') # e.g., "Monday, June 10, 2024" user_message = f"""Extract facts from the following text chunk. Chunk: {chunk_index + 1}/{total_chunks} -Event Date: {event_date.isoformat()} +Event Date: {event_date_formatted} ({event_date.isoformat()}) Context: {context if context else 'none'} Text: diff --git a/hindsight-api/hindsight_api/engine/search/retrieval.py b/hindsight-api/hindsight_api/engine/search/retrieval.py index 2fce8831..fdb23a8b 100644 --- a/hindsight-api/hindsight_api/engine/search/retrieval.py +++ b/hindsight-api/hindsight_api/engine/search/retrieval.py @@ -228,7 +228,7 @@ async def retrieve_temporal( start_date: datetime, end_date: datetime, budget: int, - semantic_threshold: float = 0.4 + semantic_threshold: float = 0.1 ) -> List[RetrievalResult]: """ Temporal retrieval with spreading activation. @@ -287,6 +287,9 @@ async def retrieve_temporal( query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold ) + import logging + logger = logging.getLogger(__name__) + if not entry_points: # Check if there are ANY memories with temporal metadata for this bank total_with_dates = await conn.fetchval( @@ -295,9 +298,28 @@ async def retrieve_temporal( AND (occurred_start IS NOT NULL OR occurred_end IS NOT NULL OR mentioned_at IS NOT NULL)""", bank_id, fact_type ) - import logging - logger = logging.getLogger(__name__) - logger.info(f"[TEMPORAL] No entry points found for {bank_id}/{fact_type} in range {start_date} to {end_date}. Total facts with dates: {total_with_dates}") + # Check how many have mentioned_at in the range + in_range = await conn.fetchval( + """SELECT COUNT(*) FROM memory_units + WHERE bank_id = $1 AND fact_type = $2 + AND mentioned_at IS NOT NULL AND mentioned_at BETWEEN $3 AND $4""", + bank_id, fact_type, start_date, end_date + ) + # Check semantic similarity of those in range + sample = await conn.fetch( + """SELECT id, text, mentioned_at, 1 - (embedding <=> $1::vector) AS similarity + FROM memory_units + WHERE bank_id = $2 AND fact_type = $3 + AND mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5 + AND embedding IS NOT NULL + ORDER BY mentioned_at DESC + LIMIT 5""", + query_emb_str, bank_id, fact_type, start_date, end_date + ) + logger.info(f"[TEMPORAL] No entry points for {bank_id}/{fact_type} in {start_date} to {end_date}.") + logger.info(f"[TEMPORAL] Total with dates: {total_with_dates}, In date range: {in_range}") + for row in sample: + logger.info(f"[TEMPORAL] Sample: {row['text'][:60]}... mentioned_at={row['mentioned_at']} sim={row['similarity']:.3f}") return [] # Calculate temporal scores for entry points @@ -430,7 +452,7 @@ async def retrieve_parallel( thinking_budget: int, question_date: Optional[datetime] = None, query_analyzer: Optional["QueryAnalyzer"] = None -) -> Tuple[List[RetrievalResult], List[RetrievalResult], List[RetrievalResult], Optional[List[RetrievalResult]], Dict[str, float]]: +) -> Tuple[List[RetrievalResult], List[RetrievalResult], List[RetrievalResult], Optional[List[RetrievalResult]], Dict[str, float], Optional[Tuple[datetime, datetime]]]: """ Run 3-way or 4-way parallel retrieval (adds temporal if detected). @@ -445,10 +467,11 @@ async def retrieve_parallel( query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer) Returns: - Tuple of (semantic_results, bm25_results, graph_results, temporal_results, timings) + Tuple of (semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint) Each results list contains RetrievalResult objects temporal_results is None if no temporal constraint detected timings is a dict with per-method latencies in seconds + temporal_constraint is the (start_date, end_date) tuple if detected, else None """ # Detect temporal constraint from .temporal_extraction import extract_temporal_constraint @@ -459,7 +482,6 @@ async def retrieve_parallel( temporal_constraint = extract_temporal_constraint( query_text, reference_date=question_date, analyzer=query_analyzer ) - logger.info(f"[TEMPORAL] Query: {query_text[:50]}... -> constraint={temporal_constraint}") # Wrapper to track timing for each retrieval method async def timed_retrieval(name: str, coro): @@ -484,7 +506,7 @@ async def retrieve_parallel( async with acquire_with_retry(pool) as conn: return await retrieve_temporal( conn, query_embedding_str, bank_id, fact_type, - start_date, end_date, budget=thinking_budget, semantic_threshold=0.4 + start_date, end_date, budget=thinking_budget, semantic_threshold=0.1 ) # Run retrievals in parallel with timing @@ -512,4 +534,4 @@ async def retrieve_parallel( graph_results, _, timings["graph"] = results[2] temporal_results = None - return semantic_results, bm25_results, graph_results, temporal_results, timings + return semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint diff --git a/hindsight-api/tests/test_query_analyzer.py b/hindsight-api/tests/test_query_analyzer.py index 591120ea..2f892ffb 100644 --- a/hindsight-api/tests/test_query_analyzer.py +++ b/hindsight-api/tests/test_query_analyzer.py @@ -232,3 +232,54 @@ def test_query_analyzer_last_weekend(query_analyzer): assert analysis.temporal_constraint.end_date.day == 12 # Sunday +def test_query_analyzer_couple_days_ago(query_analyzer): + """Test extraction of 'a couple of days ago' colloquial expression.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + query = "I mentioned cooking something for my friend a couple of days ago. What was it?" + analysis = query_analyzer.analyze(query, reference_date) + + print(f"\nQuery: '{query}'") + print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") + print(f"Analysis: {analysis}") + + assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a couple of days ago'" + # Range should be 1-3 days ago: Jan 12-14 + assert analysis.temporal_constraint.start_date.day == 12 + assert analysis.temporal_constraint.end_date.day == 14 + + +def test_query_analyzer_few_days_ago(query_analyzer): + """Test extraction of 'a few days ago' colloquial expression.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + query = "What did I do a few days ago?" + analysis = query_analyzer.analyze(query, reference_date) + + print(f"\nQuery: '{query}'") + print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") + print(f"Analysis: {analysis}") + + assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a few days ago'" + # Range should be 2-5 days ago: Jan 10-13 + assert analysis.temporal_constraint.start_date.day == 10 + assert analysis.temporal_constraint.end_date.day == 13 + + +def test_query_analyzer_couple_weeks_ago(query_analyzer): + """Test extraction of 'a couple of weeks ago' colloquial expression.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + query = "a couple of weeks ago we discussed this" + analysis = query_analyzer.analyze(query, reference_date) + + print(f"\nQuery: '{query}'") + print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") + print(f"Analysis: {analysis}") + + assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a couple of weeks ago'" + # Range should be 1-3 weeks ago + assert analysis.temporal_constraint.start_date.month == 12 # Dec 25 (3 weeks before Jan 15) + assert analysis.temporal_constraint.end_date.month == 1 # Jan 8 (1 week before Jan 15) + + diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts index 2b6384c7..2bfdbe26 100644 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ b/hindsight-control-plane/src/app/api/recall/route.ts @@ -5,9 +5,9 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const bankId = body.bank_id || body.agent_id || 'default'; - const { query, types, fact_type, max_tokens, trace, budget, include } = body; + const { query, types, fact_type, max_tokens, trace, budget, include, query_timestamp } = body; - console.log('[Recall API] Request:', { bankId, query, types: types || fact_type, max_tokens, trace, budget }); + console.log('[Recall API] Request:', { bankId, query, types: types || fact_type, max_tokens, trace, budget, query_timestamp }); console.log('[Recall API] Include options:', JSON.stringify(include, null, 2)); const response = await sdk.recallMemories({ @@ -20,6 +20,7 @@ export async function POST(request: NextRequest) { trace, budget: budget || 'mid', include, + query_timestamp, }, }); diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 64e7f86d..10dfe590 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -320,8 +320,8 @@ export function DataView({ factType }: DataViewProps) { )} {viewMode === 'table' && ( -
When is the query being asked
+