665 lines
23 KiB
Python
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,
|
|
)
|