honcho/src/deriver/deriver.py

665 lines
23 KiB
Python

import datetime
import json
import logging
import time
from typing import Any
import sentry_sdk
from langfuse.decorators import langfuse_context
from src import crud, exceptions
from src.config import settings
from src.crud.representation import GLOBAL_REPRESENTATION_COLLECTION_NAME
from src.dependencies import tracked_db
from src.utils import summarizer
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
from src.utils.formatting import (
REASONING_LEVELS,
extract_observation_content,
find_new_observations,
format_context_for_prompt,
format_new_turn_with_timestamp,
utc_now_iso,
)
from src.utils.logging import (
accumulate_metric,
conditional_observe,
format_reasoning_inputs_as_markdown,
format_reasoning_response_as_markdown,
log_observations_tree,
log_performance_metrics,
log_thinking_panel,
)
from src.utils.shared_models import (
DeductiveObservation,
ObservationContext,
PeerCardQuery,
ReasoningResponse,
ReasoningResponseWithThinking,
UnifiedObservation,
)
from .prompts import critical_analysis_prompt, peer_card_prompt
from .queue_payload import (
RepresentationPayload,
)
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
@honcho_llm_call(
provider=settings.DERIVER.PROVIDER,
model=settings.DERIVER.MODEL,
track_name="Critical Analysis Call",
response_model=ReasoningResponse,
json_mode=True,
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS,
thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS
if settings.DERIVER.PROVIDER == "anthropic"
else None,
enable_retry=True,
retry_attempts=3,
)
async def critical_analysis_call(
peer_id: str,
peer_card: list[str] | None,
message_created_at: datetime.datetime,
working_representation: str | None,
history: str,
new_turns: list[str],
):
return critical_analysis_prompt(
peer_id=peer_id,
peer_card=peer_card,
message_created_at=message_created_at,
working_representation=working_representation,
history=history,
new_turns=new_turns,
)
@honcho_llm_call(
provider=settings.DERIVER.PEER_CARD_PROVIDER,
model=settings.DERIVER.PEER_CARD_MODEL,
track_name="Peer Card Call",
response_model=PeerCardQuery,
json_mode=True,
max_tokens=settings.DERIVER.PEER_CARD_MAX_OUTPUT_TOKENS
or settings.LLM.DEFAULT_MAX_TOKENS,
reasoning_effort="minimal",
enable_retry=True,
retry_attempts=1, # unstructured output means we shouldn't need to retry, 1 just in case
)
async def peer_card_call(
old_peer_card: list[str] | None,
new_observations: list[str],
):
return peer_card_prompt(
old_peer_card=old_peer_card,
new_observations=new_observations,
)
@sentry_sdk.trace
async def process_representation_tasks_batch(
payloads: list[RepresentationPayload],
) -> None:
"""
Process a batch of representation tasks by extracting insights and updating working representations.
"""
if not payloads:
return
payloads.sort(key=lambda x: x.created_at)
latest_payload = payloads[-1]
earliest_payload = payloads[0]
# Start overall timing
overall_start = time.perf_counter()
logger.debug(
"Starting insight extraction for message batch starting with: %s",
earliest_payload.message_id,
)
# Use get_session_context_formatted with configurable token limit
async with tracked_db("deriver.get_session_context") as db:
formatted_history = (
await summarizer.get_session_context_formatted( # NEED TO FIX?
db,
latest_payload.workspace_name,
latest_payload.session_name,
token_limit=settings.DERIVER.CONTEXT_TOKEN_LIMIT,
cutoff=latest_payload.message_id,
include_summary=True,
)
)
# instantiate embedding store from collection
# if the sender is also the target, we're handling a global representation task.
# otherwise, we're handling a directional representation task where the sender is
# being observed by the target.
collection_name = (
crud.construct_collection_name(
observer=latest_payload.target_name, observed=latest_payload.sender_name
)
if latest_payload.sender_name != latest_payload.target_name
else GLOBAL_REPRESENTATION_COLLECTION_NAME
)
# get_or_create_collection already handles IntegrityError with rollback and a retry
async with tracked_db("deriver.get_or_create_collection") as db:
collection = await crud.get_or_create_collection(
db,
latest_payload.workspace_name,
collection_name,
latest_payload.sender_name,
)
collection_name_loaded = collection.name
# Use the embedding store directly
embedding_store = EmbeddingStore(
workspace_name=latest_payload.workspace_name,
peer_name=latest_payload.sender_name,
collection_name=collection_name_loaded,
)
# Create reasoner instance
reasoner = CertaintyReasoner(embedding_store=embedding_store, ctx=payloads)
# Check for existing working representation first, fall back to global search
async with tracked_db("deriver.get_working_representation_data") as db:
working_rep_data: (
dict[str, Any] | str | None
) = await crud.get_working_representation_data(
db,
latest_payload.workspace_name,
latest_payload.target_name,
latest_payload.sender_name,
latest_payload.session_name,
)
# Time context preparation
context_prep_start = time.perf_counter()
if (
working_rep_data
and isinstance(working_rep_data, dict)
and working_rep_data.get("final_observations")
):
# Reconstruct ReasoningResponse from stored peer data
final_obs: dict[str, Any] = working_rep_data["final_observations"]
deductive_observations: list[DeductiveObservation] = []
for deductive_data in final_obs.get("deductive", []):
deductive_observations.append(
DeductiveObservation(
conclusion=deductive_data["conclusion"],
premises=deductive_data.get("premises", []),
)
)
working_representation = ReasoningResponseWithThinking(
thinking=final_obs.get("thinking"),
explicit=final_obs.get("explicit", []),
deductive=deductive_observations,
)
logger.info(
"Using existing working representation with %s explicit, %s deductive observations",
len(working_representation.explicit),
len(working_representation.deductive),
)
else:
# No existing working representation, use global search
# For the first turn of a batch, we need some query text to get relevant observations.
# We'll use the content of the first message in the batch.
query_text = [payload.content for payload in payloads]
query_text = "\n".join(
query_text
) # we probably want to think about how to handle this better
working_representation = await embedding_store.get_relevant_observations(
query=query_text,
conversation_context=formatted_history,
for_reasoning=True,
)
working_representation = observation_context_to_reasoning_response(
working_representation
)
logger.info("No working representation found, using global semantic search")
context_prep_duration = (time.perf_counter() - context_prep_start) * 1000
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"context_preparation",
context_prep_duration,
"ms",
)
# Run consolidated reasoning that handles explicit and deductive levels
logger.debug(
"REASONING: Running unified insight derivation across explicit and deductive reasoning levels"
)
async with tracked_db("deriver.get_peer_card") as db:
speaker_peer_card: list[str] | None = await crud.get_peer_card(
db,
latest_payload.workspace_name,
latest_payload.sender_name,
latest_payload.target_name,
)
if speaker_peer_card is None:
logger.warning("No peer card found for %s", latest_payload.sender_name)
else:
logger.info("Using peer card: %s", speaker_peer_card)
# Run single-pass reasoning
final_observations = await reasoner.reason(
working_representation,
formatted_history,
speaker_peer_card,
payloads,
)
logger.debug("REASONING COMPLETION: Unified reasoning completed across all levels.")
# Display final observations in a beautiful tree
final_obs_dict = {
level: getattr(final_observations, level, []) for level in REASONING_LEVELS
}
log_observations_tree(final_obs_dict)
# Always save working representation to peer for dialectic access
await save_working_representation_to_peer(latest_payload, final_observations)
# Calculate and log overall timing
overall_duration = (time.perf_counter() - overall_start) * 1000
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"total_processing_time",
overall_duration,
"ms",
)
total_observations = sum(len(obs_list) for obs_list in final_obs_dict.values())
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"final_observation_count",
total_observations,
"",
)
log_performance_metrics(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}"
)
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_trace(
output=format_reasoning_response_as_markdown(final_observations)
)
# The old function now just calls the batch processor with a single payload
async def process_representation_task(
payload: RepresentationPayload,
) -> None:
await process_representation_tasks_batch([payload])
class CertaintyReasoner:
"""Certainty reasoner for analyzing and deriving insights."""
embedding_store: EmbeddingStore
ctx: list[RepresentationPayload]
def __init__(
self, embedding_store: EmbeddingStore, ctx: list[RepresentationPayload]
) -> None:
self.embedding_store = embedding_store
self.ctx = ctx
@conditional_observe
@sentry_sdk.trace
async def derive_new_insights(
self,
working_representation: ReasoningResponseWithThinking,
history: str,
speaker_peer_card: list[str] | None,
) -> ReasoningResponseWithThinking:
"""
Critically analyzes and revises understanding, returning structured observations.
"""
# For logging, we can just show the content of the last message
latest_payload = self.ctx[-1]
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
input=format_reasoning_inputs_as_markdown(
working_representation,
history,
latest_payload.content,
latest_payload.created_at,
)
)
new_turns = [
format_new_turn_with_timestamp(p.content, p.created_at, p.sender_name)
for p in self.ctx
]
formatted_working_representation = format_context_for_prompt(
working_representation
)
logger.debug(
"CRITICAL ANALYSIS: message_created_at='%s', new_turns_count=%s",
latest_payload.created_at,
len(new_turns),
)
try:
response_obj = await critical_analysis_call(
peer_id=latest_payload.sender_name,
peer_card=speaker_peer_card,
message_created_at=latest_payload.created_at,
working_representation=formatted_working_representation,
history=history,
new_turns=new_turns,
)
except Exception as e:
raise exceptions.LLMError(
speaker_peer_card=speaker_peer_card,
working_representation=formatted_working_representation,
history=history,
new_turns=new_turns,
) from e
# If response is a string, try to parse as JSON
if isinstance(response_obj, str):
try:
response_data = json.loads(response_obj)
new_insights = ReasoningResponse(
explicit=response_data.get("explicit", []),
deductive=[
DeductiveObservation(**item)
for item in response_data.get("deductive", [])
],
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
logger.warning("Failed to parse string response as JSON: %s", e)
new_insights = ReasoningResponse(explicit=[], deductive=[])
else:
# If response is already a ReasoningResponse object
new_insights = response_obj
# Extract thinking content from the response
thinking: str | None = None
try:
# Try to get thinking from the response object using getattr for safety
response_attr = getattr(response_obj, "_response", None)
if response_attr:
thinking = getattr(response_attr, "thinking", None)
else:
thinking = getattr(response_obj, "thinking", None)
if thinking is None:
logger.debug("No thinking content found in response")
except (AttributeError, TypeError) as e:
logger.warning("Error accessing thinking content: %s, setting to None", e)
thinking = None
response = ReasoningResponseWithThinking(
thinking=thinking,
explicit=new_insights.explicit,
deductive=new_insights.deductive,
)
logger.debug(
"🚀 DEBUG: new_insights=%s, thinking_length=%s",
new_insights,
len(thinking) if thinking else 0,
)
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
output=format_reasoning_response_as_markdown(response),
)
return response
@conditional_observe
@sentry_sdk.trace
async def reason(
self,
working_representation: ReasoningResponseWithThinking,
history: str,
speaker_peer_card: list[str] | None,
) -> ReasoningResponseWithThinking:
"""
Single-pass reasoning function that critically analyzes and derives insights.
Performs one analysis pass and returns the final observations.
"""
latest_payload = self.ctx[-1]
analysis_start = time.perf_counter()
# Perform critical analysis to get observation lists
reasoning_response = await self.derive_new_insights(
working_representation,
history,
speaker_peer_card,
)
# Output the thinking content for this analysis
log_thinking_panel(reasoning_response.thinking)
analysis_duration_ms = (time.perf_counter() - analysis_start) * 1000
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"critical_analysis_duration",
analysis_duration_ms,
"ms",
)
save_observations_start = time.perf_counter()
# Save only the NEW observations that weren't in the original context
new_observations_by_level: dict[
str, list[str]
] = await self._save_new_observations(
working_representation, reasoning_response, latest_payload
)
save_observations_duration = (
time.perf_counter() - save_observations_start
) * 1000
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"save_new_observations",
save_observations_duration,
"ms",
)
update_peer_card_start = time.perf_counter()
# flatten new observations by level into a list
new_observations = [
extract_observation_content(observation)
for level in new_observations_by_level.values()
for observation in level
]
if new_observations:
await self._update_peer_card(speaker_peer_card, new_observations)
update_peer_card_duration = (
time.perf_counter() - update_peer_card_start
) * 1000
accumulate_metric(
f"deriver_representation_{latest_payload.message_id}_{latest_payload.target_name}",
"update_peer_card",
update_peer_card_duration,
"ms",
)
return reasoning_response
@conditional_observe
@sentry_sdk.trace
async def _save_new_observations(
self,
original_working_representation: ReasoningResponse,
revised_observations: ReasoningResponse,
latest_payload: RepresentationPayload,
) -> dict[str, list[str]]:
"""Save only the observations that are new compared to the original context."""
# Use the utility function to find new observations
new_observations_by_level: dict[str, list[str]] = find_new_observations(
original_working_representation, revised_observations
)
all_unified_observations: list[UnifiedObservation] = []
total_observations_count: int = 0
for level, new_observations in new_observations_by_level.items():
if not new_observations:
logger.debug("No new observations to save for %s level", level)
continue
logger.debug("Found %s new %s observations", len(new_observations), level)
# Convert each observation to UnifiedObservation with proper premises and level
for observation in new_observations:
if isinstance(observation, DeductiveObservation):
# Create UnifiedObservation with premises from DeductiveObservation
unified_obs = UnifiedObservation(
conclusion=observation.conclusion,
premises=observation.premises,
level=level,
)
all_unified_observations.append(unified_obs)
logger.debug(
"Added %s observation: %s... with %s premises",
level,
observation.conclusion[:50],
len(observation.premises),
)
else:
# String observations (explicit) have no premises
unified_obs = UnifiedObservation.from_string(
observation, level=level
)
all_unified_observations.append(unified_obs)
logger.debug("Added %s observation: %s...", level, observation[:50])
total_observations_count += 1
if all_unified_observations:
await self.embedding_store.save_unified_observations(
all_unified_observations,
latest_payload.message_id,
latest_payload.session_name,
latest_payload.created_at,
)
else:
logger.debug("No new observations to save")
return new_observations_by_level
@conditional_observe
@sentry_sdk.trace
async def _update_peer_card(
self,
old_peer_card: list[str] | None,
new_observations: list[str],
) -> None:
"""
Update the peer card by calling LLM with the old peer card and new observations.
The new peer card is returned by the LLM and saved to peer internal metadata.
"""
try:
response = await peer_card_call(old_peer_card, new_observations)
new_peer_card = response.card
if not new_peer_card:
logger.info("No changes to peer card")
return
# even with a dedicated notes field, we still need to prune notes out of the card
new_peer_card = [
observation
for observation in new_peer_card
if not observation.lower().startswith("notes")
]
logger.info("New peer card: %s", new_peer_card)
async with tracked_db("deriver.update_peer_card") as db:
await crud.set_peer_card(
db,
self.ctx[0].workspace_name,
self.ctx[0].sender_name,
self.ctx[0].target_name,
new_peer_card,
)
except Exception as e:
if settings.SENTRY.ENABLED:
sentry_sdk.capture_exception(e)
logger.error("Error updating peer card! Skipping... %s", e)
def observation_context_to_reasoning_response(
context: ObservationContext,
) -> ReasoningResponseWithThinking:
"""Convert ObservationContext to ReasoningResponse for compatibility."""
thinking = context.thinking
# Convert explicit observations to new structure
explicit: list[str] = []
for obs in context.explicit:
explicit.append(obs.content)
# Convert deductive observations
deductive: list[DeductiveObservation] = []
for obs in context.deductive:
deductive_obs = DeductiveObservation(
conclusion=obs.content,
premises=obs.metadata.premises if obs.metadata else [],
)
deductive.append(deductive_obs)
return ReasoningResponseWithThinking(
thinking=thinking,
explicit=explicit,
deductive=deductive,
)
@sentry_sdk.trace
async def save_working_representation_to_peer(
payload: RepresentationPayload,
final_observations: ReasoningResponseWithThinking,
) -> None:
"""Save working representation to peer internal_metadata for dialectic access."""
# Convert ReasoningResponse to serializable dict
final_obs_dict = {
"thinking": final_observations.thinking,
"explicit": final_observations.explicit,
"deductive": [
{
"conclusion": obs.conclusion,
"premises": obs.premises,
}
for obs in final_observations.deductive
],
}
working_rep_data = {
"final_observations": final_obs_dict,
"message_id": payload.message_id,
"created_at": utc_now_iso(),
}
async with tracked_db("deriver.save_working_representation") as db:
await crud.set_working_representation(
db,
working_rep_data,
payload.workspace_name,
payload.target_name,
payload.sender_name,
payload.session_name,
)