import datetime import ipaddress from enum import Enum from typing import Annotated, Any, Self from urllib.parse import urlparse import tiktoken from pydantic import ( AliasChoices, BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator, ) from src.config import ReasoningLevel, settings from src.utils.types import DocumentLevel RESOURCE_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$" class DreamType(str, Enum): """Types of dreams that can be triggered.""" OMNI = "omni" class ReasoningConfiguration(BaseModel): enabled: bool | None = Field( default=None, description="Whether to enable reasoning functionality.", ) custom_instructions: str | None = Field( default=None, description="TODO: currently unused. Custom instructions to use for the reasoning system on this workspace/session/message.", ) class PeerCardConfiguration(BaseModel): use: bool | None = Field( default=None, description="Whether to use peer card related to this peer during reasoning process.", ) create: bool | None = Field( default=None, description="Whether to generate peer card based on content.", ) class SummaryConfiguration(BaseModel): enabled: bool | None = Field( default=None, description="Whether to enable summary functionality.", ) messages_per_short_summary: int | None = Field( default=None, ge=10, description="Number of messages per short summary. Must be positive, greater than or equal to 10, and less than messages_per_long_summary.", ) messages_per_long_summary: int | None = Field( default=None, ge=20, description="Number of messages per long summary. Must be positive, greater than or equal to 20, and greater than messages_per_short_summary.", ) @model_validator(mode="after") def validate_summary_thresholds(self) -> Self: """Validate that short summary threshold <= long summary threshold.""" short = self.messages_per_short_summary long = self.messages_per_long_summary if short is not None and long is not None and short >= long: raise ValueError( "messages_per_short_summary must be less than messages_per_long_summary" ) return self class DreamConfiguration(BaseModel): enabled: bool | None = Field( default=None, description="Whether to enable dream functionality. If reasoning is disabled, dreams will also be disabled and this setting will be ignored.", ) class WorkspaceConfiguration(BaseModel): """ The set of options that can be in a workspace DB-level configuration dictionary. All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. """ model_config = ConfigDict(extra="allow") # pyright: ignore reasoning: ReasoningConfiguration | None = Field( default=None, description="Configuration for reasoning functionality.", ) peer_card: PeerCardConfiguration | None = Field( default=None, description="Configuration for peer card functionality. If reasoning is disabled, peer cards will also be disabled and these settings will be ignored.", ) summary: SummaryConfiguration | None = Field( default=None, description="Configuration for summary functionality.", ) dream: DreamConfiguration | None = Field( default=None, description="Configuration for dream functionality. If reasoning is disabled, dreams will also be disabled and these settings will be ignored.", ) class SessionConfiguration(WorkspaceConfiguration): """ The set of options that can be in a session DB-level configuration dictionary. All fields are optional. Session-level configuration overrides workspace-level configuration, which overrides global configuration. """ pass class MessageConfiguration(BaseModel): """ The set of options that can be in a message DB-level configuration dictionary. All fields are optional. Message-level configuration overrides all other configurations. """ reasoning: ReasoningConfiguration | None = Field( default=None, description="Configuration for reasoning functionality.", ) class ResolvedReasoningConfiguration(BaseModel): enabled: bool class ResolvedPeerCardConfiguration(BaseModel): use: bool create: bool class ResolvedSummaryConfiguration(BaseModel): enabled: bool messages_per_short_summary: int messages_per_long_summary: int class ResolvedDreamConfiguration(BaseModel): enabled: bool class ResolvedConfiguration(BaseModel): """ The final resolved configuration for a given message. Hierarchy: message > session > workspace > global configuration """ reasoning: ResolvedReasoningConfiguration peer_card: ResolvedPeerCardConfiguration summary: ResolvedSummaryConfiguration dream: ResolvedDreamConfiguration class PeerConfig(BaseModel): # TODO: Update description - should say "Whether honcho forms a representation of the peer itself" observe_me: bool | None = Field( default=None, description="Whether Honcho will use reasoning to form a representation of this peer", ) class SessionPeerConfig(PeerConfig): # TODO: Update description - should say "Whether this peer forms representations of other peers in the session" observe_others: bool | None = Field( default=None, description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session", ) class WorkspaceBase(BaseModel): pass class WorkspaceCreate(WorkspaceBase): name: Annotated[ str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] metadata: dict[str, Any] = {} configuration: WorkspaceConfiguration = Field( default_factory=WorkspaceConfiguration ) model_config = ConfigDict(populate_by_name=True) # pyright: ignore class WorkspaceGet(WorkspaceBase): filters: dict[str, Any] | None = None class WorkspaceUpdate(WorkspaceBase): metadata: dict[str, Any] | None = None configuration: WorkspaceConfiguration | None = None class Workspace(WorkspaceBase): name: str = Field(serialization_alias="id") h_metadata: dict[str, Any] = Field( default_factory=dict, serialization_alias="metadata" ) configuration: dict[str, Any] = Field(default_factory=dict) created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class PeerBase(BaseModel): pass class PeerCreate(PeerBase): name: Annotated[ str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] metadata: dict[str, Any] | None = None configuration: dict[str, Any] | None = None model_config = ConfigDict(populate_by_name=True) # pyright: ignore class PeerGet(PeerBase): filters: dict[str, Any] | None = None class PeerUpdate(PeerBase): metadata: dict[str, Any] | None = None configuration: dict[str, Any] | None = None class Peer(PeerBase): name: str = Field(serialization_alias="id") workspace_name: str = Field(serialization_alias="workspace_id") created_at: datetime.datetime h_metadata: dict[str, Any] = Field( default_factory=dict, serialization_alias="metadata" ) configuration: dict[str, Any] = Field(default_factory=dict) model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class PeerRepresentationGet(BaseModel): session_id: str | None = Field( None, description="Optional session ID within which to scope the representation" ) target: str | None = Field( None, description="Optional peer ID to get the representation for, from the perspective of this peer", ) search_query: str | None = Field( None, description="Optional input to curate the representation around semantic search results", ) search_top_k: int | None = Field( None, ge=1, le=100, description="Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include in the representation", ) search_max_distance: float | None = Field( None, ge=0.0, le=1.0, description="Only used if `search_query` is provided. Maximum distance to search for semantically relevant conclusions", ) include_most_frequent: bool | None = Field( default=None, description="Only used if `search_query` is provided. Whether to include the most frequent conclusions in the representation", ) max_conclusions: int | None = Field( default=25, ge=1, le=100, description="Only used if `search_query` is provided. Maximum number of conclusions to include in the representation", ) class RepresentationResponse(BaseModel): representation: str class PeerCardResponse(BaseModel): peer_card: list[str] | None = Field( None, description="The peer card content, or None if not found" ) class PeerCardSet(BaseModel): peer_card: list[str] = Field(..., description="The peer card content to set") class MessageBase(BaseModel): pass class MessageCreate(MessageBase): content: Annotated[str, Field(min_length=0, max_length=settings.MAX_MESSAGE_SIZE)] peer_name: str = Field(alias="peer_id") metadata: dict[str, Any] | None = None configuration: MessageConfiguration | None = None created_at: datetime.datetime | None = None _encoded_message: list[int] = PrivateAttr(default=[]) @property def encoded_message(self) -> list[int]: return self._encoded_message @model_validator(mode="after") def validate_and_set_token_count(self) -> Self: encoding = tiktoken.get_encoding("o200k_base") encoded_message = encoding.encode(self.content) self._encoded_message = encoded_message return self class MessageGet(MessageBase): filters: dict[str, Any] | None = None class MessageUpdate(MessageBase): metadata: dict[str, Any] | None = None class Message(MessageBase): public_id: str = Field(serialization_alias="id") content: str peer_name: str = Field(serialization_alias="peer_id") session_name: str = Field(serialization_alias="session_id") h_metadata: dict[str, Any] = Field( default_factory=dict, serialization_alias="metadata" ) created_at: datetime.datetime workspace_name: str = Field(serialization_alias="workspace_id") token_count: int model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class MessageBatchCreate(BaseModel): """Schema for batch message creation with a max of 100 messages""" messages: list[MessageCreate] = Field(..., min_length=1, max_length=100) class MessageUploadCreate(BaseModel): """Schema for message creation from file uploads""" peer_id: str = Field(..., description="ID of the peer creating the message") metadata: dict[str, Any] | None = None configuration: MessageConfiguration | None = None created_at: datetime.datetime | None = None model_config = ConfigDict(populate_by_name=True) # pyright: ignore class SessionBase(BaseModel): pass class SessionCreate(SessionBase): name: Annotated[ str, Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN), ] metadata: dict[str, Any] | None = None peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers") configuration: SessionConfiguration | None = None model_config = ConfigDict(populate_by_name=True) # pyright: ignore class SessionGet(SessionBase): filters: dict[str, Any] | None = None class SessionUpdate(SessionBase): metadata: dict[str, Any] | None = None configuration: SessionConfiguration | None = None class Session(SessionBase): name: str = Field(serialization_alias="id") is_active: bool workspace_name: str = Field(serialization_alias="workspace_id") h_metadata: dict[str, Any] = Field( default_factory=dict, serialization_alias="metadata" ) configuration: dict[str, Any] = Field(default_factory=dict) created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class Summary(BaseModel): content: str = Field(description="The summary text") message_id: int = Field( description="The internal ID of the message that this summary covers up to", exclude=True, ) message_public_id: str = Field( description="The public ID of the message that this summary covers up to", serialization_alias="message_id", ) summary_type: str = Field(description="The type of summary (short or long)") created_at: str = Field( description="The timestamp of when the summary was created (ISO format)" ) token_count: int = Field(description="The number of tokens in the summary text") class SessionContext(SessionBase): name: str = Field(serialization_alias="id") messages: list[Message] summary: Summary | None = Field( default=None, description="The summary if available" ) peer_representation: str | None = Field( default=None, description="A curated subset of a peer representation, if context is requested from a specific perspective", ) peer_card: list[str] | None = Field( default=None, description="The peer card, if context is requested from a specific perspective", ) model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class PeerContext(BaseModel): """Context for a peer, including representation and peer card.""" peer_id: str = Field(description="The ID of the peer") target_id: str = Field(description="The ID of the target peer being observed") representation: str | None = Field( default=None, description="A curated subset of the representation of the target peer from the observer's perspective", ) peer_card: list[str] | None = Field( default=None, description="The peer card for the target peer from the observer's perspective", ) class SessionSummaries(SessionBase): name: str = Field(serialization_alias="id") short_summary: Summary | None = Field( default=None, description="The short summary if available" ) long_summary: Summary | None = Field( default=None, description="The long summary if available" ) model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True ) class DocumentBase(BaseModel): pass class DocumentMetadata(BaseModel): message_ids: list[int] = Field( description="The ID range(s) of the messages that this document was derived from. Acts as a link to the primary source of the document. Note that as a document gets deduplicated, additional ranges will be added, because the same document could be derived from completely separate message ranges." ) message_created_at: str = Field( description="The timestamp of the message that this document was derived from. Note that this is not the same as the created_at timestamp of the document. This timestamp is usually only saved with second-level precision." ) source_ids: list[str] | None = Field( default=None, description="Document IDs of source documents for tree traversal -- required for deductive and inductive documents", ) premises: list[str] | None = Field( default=None, description="Human-readable premise text for display -- only applicable for deductive documents", ) sources: list[str] | None = Field( default=None, description="Human-readable source text for display -- only applicable for inductive documents", ) pattern_type: str | None = Field( default=None, description="Type of pattern identified (preference, behavior, personality, tendency, correlation) -- only applicable for inductive documents", ) confidence: str | None = Field( default=None, description="Confidence level (high, medium, low) -- only applicable for inductive documents", ) class DocumentCreate(DocumentBase): content: Annotated[str, Field(min_length=1, max_length=100000)] session_name: str = Field( description="The session from which the document was derived" ) level: DocumentLevel = Field( default="explicit", description="The level of the document (explicit, deductive, inductive, or contradiction)", ) times_derived: int = Field( default=1, ge=1, description="The number of times that a semantic duplicate document to this one has been derived", ) metadata: DocumentMetadata = Field() embedding: list[float] = Field() # Tree linkage field source_ids: list[str] | None = Field( default=None, description="Document IDs of source/premise documents -- for deductive and inductive documents", ) class ConclusionGet(BaseModel): """Schema for listing conclusions with optional filters.""" filters: dict[str, Any] | None = None class Conclusion(BaseModel): """Conclusion response - external view of a document.""" id: str content: str observer: str = Field( description="The peer who made the conclusion", serialization_alias="observer_id", ) observed: str = Field( description="The peer the conclusion is about", serialization_alias="observed_id", ) session_name: str = Field(serialization_alias="session_id") created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True, ) class ConclusionQuery(BaseModel): """Query parameters for semantic search of conclusions.""" query: str = Field(..., description="Semantic search query") top_k: int = Field( default=10, ge=1, le=100, description="Number of results to return", ) distance: float | None = Field( default=None, ge=0.0, le=1.0, description="Maximum cosine distance threshold for results", ) filters: dict[str, Any] | None = Field( default=None, description="Additional filters to apply", ) class ConclusionCreate(BaseModel): """Schema for creating a single conclusion.""" content: Annotated[str, Field(min_length=1, max_length=65535)] observer_id: str = Field(..., description="The peer making the conclusion") observed_id: str = Field(..., description="The peer the conclusion is about") session_id: str = Field(..., description="The session this conclusion relates to") _token_count: int = PrivateAttr(default=0) @model_validator(mode="after") def validate_token_count(self) -> Self: """Validate that content doesn't exceed embedding token limit.""" encoding = tiktoken.get_encoding("o200k_base") tokens = encoding.encode(self.content) self._token_count = len(tokens) if self._token_count > settings.MAX_EMBEDDING_TOKENS: raise ValueError( f"Content exceeds maximum embedding token limit of {settings.MAX_EMBEDDING_TOKENS} " + f"(got {self._token_count} tokens)" ) return self class ConclusionBatchCreate(BaseModel): """Schema for batch conclusion creation with a max of 100 conclusions.""" conclusions: list[ConclusionCreate] = Field( ..., min_length=1, max_length=100, validation_alias=AliasChoices("conclusions", "observations"), ) class MessageSearchOptions(BaseModel): query: str = Field(..., description="Search query") filters: dict[str, Any] | None = Field( default=None, description="Filters to scope the search" ) limit: int = Field( default=10, ge=1, le=100, description="Number of results to return", ) class DialecticOptions(BaseModel): session_id: str | None = Field( None, description="ID of the session to scope the representation to" ) target: str | None = Field( None, description="Optional peer to get the representation for, from the perspective of this peer", ) query: Annotated[ str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt") ] stream: bool = False reasoning_level: ReasoningLevel = Field( default="low", description="Level of reasoning to apply: minimal, low, medium, high, or max", ) class DialecticResponse(BaseModel): content: str class DialecticStreamDelta(BaseModel): """Delta object for streaming dialectic responses.""" content: str | None = None # Future fields can be added here: # premises: str | None = None # tokens: int | None = None # analytics: dict[str, Any] | None = None class DialecticStreamChunk(BaseModel): """Chunk in a streaming dialectic response.""" delta: DialecticStreamDelta done: bool = False class SessionCounts(BaseModel): """Counts for a specific session in queue processing.""" completed: int in_progress: int pending: int class QueueCounts(BaseModel): """Aggregated counts for queue processing status.""" total: int completed: int in_progress: int pending: int sessions: dict[str, SessionCounts] class QueueStatusRow(BaseModel): """Represents a row from the queue status SQL query result.""" session_id: str | None total: int completed: int in_progress: int pending: int session_total: int session_completed: int session_in_progress: int session_pending: int class SessionPeerData(BaseModel): """Data for managing session peer relationships.""" peer_names: dict[str, SessionPeerConfig] class MessageBulkData(BaseModel): """Data for bulk message operations.""" messages: list[MessageCreate] session_name: str workspace_name: str class SessionQueueStatus(BaseModel): """Status for a specific session within the processing queue.""" session_id: str | None = Field( default=None, description="Session ID if filtered by session", ) total_work_units: int = Field(description="Total work units") completed_work_units: int = Field(description="Completed work units") in_progress_work_units: int = Field( description="Work units currently being processed" ) pending_work_units: int = Field(description="Work units waiting to be processed") class QueueStatus(BaseModel): """Aggregated processing queue status.""" total_work_units: int = Field(description="Total work units") completed_work_units: int = Field(description="Completed work units") in_progress_work_units: int = Field( description="Work units currently being processed" ) pending_work_units: int = Field(description="Work units waiting to be processed") sessions: dict[str, SessionQueueStatus] | None = Field( default=None, description="Per-session status when not filtered by session", ) class ScheduleDreamRequest(BaseModel): observer: str = Field(..., description="Observer peer name") observed: str | None = Field( None, description="Observed peer name (defaults to observer if not specified)" ) dream_type: DreamType = Field(..., description="Type of dream to schedule") session_id: str = Field(..., description="Session ID to scope the dream to") # Webhook endpoint schemas class WebhookEndpointBase(BaseModel): pass class WebhookEndpointCreate(WebhookEndpointBase): url: str @field_validator("url") @classmethod def validate_webhook_url(cls, v: str) -> str: parsed = urlparse(v) if not all([parsed.scheme, parsed.netloc]): raise ValueError("Invalid URL format") # Only allow HTTP/HTTPS if parsed.scheme not in ["http", "https"]: raise ValueError("Only HTTP and HTTPS URLs are allowed") # Block private/internal addresses if parsed.hostname: try: ip_address = ipaddress.ip_address(parsed.hostname) if ip_address.is_private: raise ValueError("Private IP addresses are not allowed") except ValueError: # Not an IP address, might be a hostname pass return v class WebhookEndpoint(WebhookEndpointBase): id: str workspace_name: str | None = Field(serialization_alias="workspace_id") url: str created_at: datetime.datetime model_config = ConfigDict(from_attributes=True, populate_by_name=True) # pyright: ignore