Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion hindsight-api/hindsight_api/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import os
from pathlib import Path

from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool

from alembic import context

# Import your models here
from hindsight_api.models import Base

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "5a366d414dce"
down_revision: str | Sequence[str] | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "b7c4d8e9f1a2"
down_revision: str | Sequence[str] | None = "5a366d414dce"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "c8e5f2a3b4d1"
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import context, op

# revision identifiers, used by Alembic.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql

from alembic import context, op

# revision identifiers, used by Alembic.
revision: str = "rename_personality"
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"
Expand Down
1 change: 1 addition & 0 deletions hindsight-api/hindsight_api/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def create_app(
if mcp_api_enabled:
try:
from .mcp import create_mcp_app

mcp_app = create_mcp_app(memory=memory)
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
Expand Down
4 changes: 3 additions & 1 deletion hindsight-api/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ def from_env(cls) -> "HindsightConfig":
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Observation thresholds
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
observation_top_entities=int(os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))),
observation_top_entities=int(
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
),
)

def get_llm_base_url(self) -> str:
Expand Down
19 changes: 11 additions & 8 deletions hindsight-api/hindsight_api/engine/llm_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,7 @@ def __init__(
elif self.provider in ("ollama", "lmstudio"):
# Use dummy key if not provided for local
api_key = self.api_key or "local"
client_kwargs = {
"api_key": api_key,
"base_url": self.base_url,
"max_retries": 0
}
client_kwargs = {"api_key": api_key, "base_url": self.base_url, "max_retries": 0}
if self.timeout:
client_kwargs["timeout"] = self.timeout
self._client = AsyncOpenAI(**client_kwargs)
Expand Down Expand Up @@ -207,7 +203,14 @@ async def call(
# Handle Anthropic provider separately
if self.provider == "anthropic":
return await self._call_anthropic(
messages, response_format, max_completion_tokens, max_retries, initial_backoff, max_backoff, skip_validation, start_time
messages,
response_format,
max_completion_tokens,
max_retries,
initial_backoff,
max_backoff,
skip_validation,
start_time,
)

# Handle Ollama with native API for structured output (better schema enforcement)
Expand Down Expand Up @@ -297,8 +300,8 @@ async def call(
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
call_params["response_format"] = {"type": "json_object"}
call_params["response_format"] = {"type": "json_object"}

logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
response = await self._client.chat.completions.create(**call_params)
logger.debug(f"Received response from {self.provider}/{self.model}")
Expand Down
55 changes: 49 additions & 6 deletions hindsight-api/hindsight_api/engine/retain/fact_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,44 @@
from ..llm_wrapper import LLMConfig, OutputTooLongError


def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
"""
Infer a temporal date from fact text when LLM didn't provide occurred_start.

This is a fallback for when the LLM fails to extract temporal information
from relative time expressions like "last night", "yesterday", etc.
"""
import re

fact_lower = fact_text.lower()

# Map relative time expressions to day offsets
temporal_patterns = {
r"\blast night\b": -1,
r"\byesterday\b": -1,
r"\btoday\b": 0,
r"\bthis morning\b": 0,
r"\bthis afternoon\b": 0,
r"\bthis evening\b": 0,
r"\btonigh?t\b": 0,
r"\btomorrow\b": 1,
r"\blast week\b": -7,
r"\bthis week\b": 0,
r"\bnext week\b": 7,
r"\blast month\b": -30,
r"\bthis month\b": 0,
r"\bnext month\b": 30,
}

for pattern, offset_days in temporal_patterns.items():
if re.search(pattern, fact_lower):
target_date = event_date + timedelta(days=offset_days)
return target_date.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()

# If no relative time expression found, return None
return None


def _sanitize_text(text: str) -> str:
"""
Sanitize text by removing invalid Unicode surrogate characters.
Expand Down Expand Up @@ -676,13 +714,18 @@ def get_value(field_name):
if fact_kind == "event":
occurred_start = get_value("occurred_start")
occurred_end = get_value("occurred_end")
if occurred_start:

# If LLM didn't set temporal fields, try to extract them from the fact text
if not occurred_start:
fact_data["occurred_start"] = _infer_temporal_date(combined_text, event_date)
else:
fact_data["occurred_start"] = occurred_start
# For point events: if occurred_end not set, default to occurred_start
if occurred_end:
fact_data["occurred_end"] = occurred_end
else:
fact_data["occurred_end"] = occurred_start

# For point events: if occurred_end not set, default to occurred_start
if occurred_end:
fact_data["occurred_end"] = occurred_end
elif fact_data.get("occurred_start"):
fact_data["occurred_end"] = fact_data["occurred_start"]

# Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format
Expand Down
5 changes: 5 additions & 0 deletions hindsight-api/hindsight_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ def release_lock():
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_timeout=config.llm_timeout,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
Expand All @@ -180,6 +182,8 @@ def release_lock():
log_level=args.log_level,
mcp_enabled=config.mcp_enabled,
graph_retriever=config.graph_retriever,
observation_min_facts=config.observation_min_facts,
observation_top_entities=config.observation_top_entities,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
)
Expand All @@ -196,6 +200,7 @@ def release_lock():
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
if operation_validator:
import logging

logging.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")

# Create MemoryEngine (reads configuration from environment)
Expand Down
3 changes: 2 additions & 1 deletion hindsight-api/hindsight_api/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
import os
from pathlib import Path

from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text

from alembic import command

logger = logging.getLogger(__name__)

# Advisory lock ID for migrations (arbitrary unique number)
Expand Down
96 changes: 63 additions & 33 deletions hindsight-api/tests/test_fact_extraction_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ async def test_date_field_calculation_last_night(self):

Ideally: If conversation is on August 14, 2023 and text says "last night",
the date field should be August 13. We accept 13 or 14 as LLM may vary.

Retries up to 3 times to account for LLM inconsistencies.
"""
text = """
Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday
Expand All @@ -410,41 +412,69 @@ async def test_date_field_calculation_last_night(self):

context = "Conversation between Melanie and Caroline"
llm_config = LLMConfig.for_memory()

event_date = datetime(2023, 8, 14, 14, 24)

facts, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie"
)

assert len(facts) > 0, "Should extract at least one fact"

birthday_fact = None
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
birthday_fact = fact
break

assert birthday_fact is not None, "Should extract fact about birthday celebration"

fact_date_str = birthday_fact.occurred_start
assert fact_date_str is not None, "occurred_start should not be None for temporal events"

if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
else:
fact_date = datetime.fromisoformat(fact_date_str)

assert fact_date.year == 2023, "Year should be 2023"
assert fact_date.month == 8, "Month should be August"
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid
assert fact_date.day in (13, 14), (
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
)
last_error = None
max_retries = 3

for attempt in range(max_retries):
try:
facts, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie"
)

assert len(facts) > 0, "Should extract at least one fact"

birthday_fact = None
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
birthday_fact = fact
break

assert birthday_fact is not None, "Should extract fact about birthday celebration"

fact_date_str = birthday_fact.occurred_start
assert fact_date_str is not None, "occurred_start should not be None for temporal events"

if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
else:
fact_date = datetime.fromisoformat(fact_date_str)

assert fact_date.year == 2023, "Year should be 2023"
assert fact_date.month == 8, "Month should be August"
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid
assert fact_date.day in (13, 14), (
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
)

# If we reach here, test passed
return

except AssertionError as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e
except Exception as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed with exception: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e

# Should not reach here, but just in case
if last_error:
raise last_error

@pytest.mark.asyncio
async def test_date_field_calculation_yesterday(self):
Expand Down
1 change: 1 addition & 0 deletions hindsight-control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cytoscape": "^3.33.1",
"cytoscape-fcose": "^2.2.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0",
Expand Down
10 changes: 9 additions & 1 deletion hindsight-control-plane/src/app/api/banks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function GET() {
try {
const response = await sdk.listBanks({ client: lowLevelClient });

// Check if the response has an error or no data
if (response.error || !response.data) {
console.error("API error:", response.error);
return NextResponse.json({ error: "Failed to fetch banks from API" }, { status: 500 });
}

return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error fetching banks:", error);
Expand All @@ -26,7 +33,8 @@ export async function POST(request: Request) {
body: {},
});

return NextResponse.json(response.data, { status: 201 });
const serializedData = JSON.parse(JSON.stringify(response.data));
return NextResponse.json(serializedData, { status: 201 });
} catch (error) {
console.error("Error creating bank:", error);
return NextResponse.json({ error: "Failed to create bank" }, { status: 500 });
Expand Down
Loading