From fdef895bb50a9ae0ec5cbdd3a3c2160038f4d51a Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Thu, 20 Aug 2026 07:38:05 +0800 Subject: [PATCH 1/2] feat(config): make representation order configurable --- .env.template | 1 + config.toml.example | 1 + docs/v3/contributing/configuration.mdx | 11 + src/config.py | 35 +++ src/utils/representation.py | 208 +++++++--------- tests/utils/test_representation_order.py | 288 +++++++++++++++++++++++ 6 files changed, 425 insertions(+), 119 deletions(-) create mode 100644 tests/utils/test_representation_order.py diff --git a/.env.template b/.env.template index 2f737dba..3a977893 100644 --- a/.env.template +++ b/.env.template @@ -11,6 +11,7 @@ LOG_LEVEL=INFO PERFORMANCE_LOG_FORMAT=compact # compact|rich # SESSION_OBSERVERS_LIMIT=10 # GET_CONTEXT_MAX_TOKENS=100000 +# REPRESENTATION_INJECTION_ORDER=explicit,deductive,inductive,contradiction # MAX_FILE_SIZE=5242880 # Bytes # MAX_MESSAGE_SIZE=25000 # Characters diff --git a/config.toml.example b/config.toml.example index 60d9092a..be995d37 100644 --- a/config.toml.example +++ b/config.toml.example @@ -9,6 +9,7 @@ LOG_LEVEL = "INFO" PERFORMANCE_LOG_FORMAT = "compact" # "compact" for single-line logs, "rich" for local panels SESSION_OBSERVERS_LIMIT = 10 GET_CONTEXT_MAX_TOKENS = 100000 +REPRESENTATION_INJECTION_ORDER = ["explicit", "deductive", "inductive", "contradiction"] MAX_FILE_SIZE = 5242880 # 5MB MAX_MESSAGE_SIZE = 25000 # Characters EMBED_MESSAGES = true diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 3c210e71..74aca441 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -522,6 +522,7 @@ DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit", "deductive"] LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL SESSION_OBSERVERS_LIMIT=10 GET_CONTEXT_MAX_TOKENS=100000 +REPRESENTATION_INJECTION_ORDER=explicit,deductive,inductive,contradiction MAX_MESSAGE_SIZE=25000 MAX_FILE_SIZE=5242880 # 5MB EMBED_MESSAGES=true @@ -530,6 +531,15 @@ EMBEDDING_MAX_TOKENS_PER_REQUEST=300000 NAMESPACE=honcho ``` +`REPRESENTATION_INJECTION_ORDER` controls the order of the explicit, deductive, +inductive, and contradiction blocks injected into prompts and returned in rendered +representations. It must contain each section exactly once. The default preserves +the existing order; to put higher-level patterns first, use: + +```bash +REPRESENTATION_INJECTION_ORDER=inductive,explicit,deductive,contradiction +``` + **Optional Integrations:** ```bash LANGFUSE_HOST=https://cloud.langfuse.com @@ -649,6 +659,7 @@ A complete config.toml with all defaults. Copy and modify what you need: [app] LOG_LEVEL = "INFO" SESSION_OBSERVERS_LIMIT = 10 +REPRESENTATION_INJECTION_ORDER = ["explicit", "deductive", "inductive", "contradiction"] EMBED_MESSAGES = true NAMESPACE = "honcho" diff --git a/src/config.py b/src/config.py index 092cb6ee..92548c7b 100644 --- a/src/config.py +++ b/src/config.py @@ -13,6 +13,7 @@ from pydantic_settings import ( BaseSettings, DotEnvSettingsSource, EnvSettingsSource, + NoDecode, PydanticBaseSettingsSource, SettingsConfigDict, ) @@ -29,6 +30,13 @@ EmbeddingTransport = Literal["openai", "gemini"] EmbeddingDimensionsMode = Literal["auto", "always", "never"] EmbeddingEncodingFormat = Literal["float", "base64"] EmbeddingEncodingFormatMode = Literal["auto", "float", "base64"] +RepresentationSection = Literal["explicit", "deductive", "inductive", "contradiction"] +REPRESENTATION_SECTIONS: tuple[RepresentationSection, ...] = ( + "explicit", + "deductive", + "inductive", + "contradiction", +) # OpenAI-compatible models that reject the `dimensions=` request parameter. _EMBEDDING_KNOWN_REJECTING_MODELS: frozenset[str] = frozenset( @@ -1495,6 +1503,9 @@ class AppSettings(HonchoSettings): GET_CONTEXT_MAX_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=250_000)] = ( 100_000 ) + REPRESENTATION_INJECTION_ORDER: Annotated[ + tuple[RepresentationSection, ...], NoDecode + ] = REPRESENTATION_SECTIONS MAX_MESSAGE_SIZE: Annotated[int, Field(default=25_000, gt=0)] = 25_000 EMBED_MESSAGES: bool = True @@ -1569,6 +1580,30 @@ class AppSettings(HonchoSettings): raise ValueError(f"Invalid performance log format: {v}") return log_format + @field_validator("REPRESENTATION_INJECTION_ORDER", mode="before") + @classmethod + def validate_representation_injection_order( + cls, value: Any + ) -> tuple[RepresentationSection, ...]: + sections: tuple[Any, ...] + if isinstance(value, str): + sections = tuple(section.strip() for section in value.split(",")) + elif isinstance(value, list | tuple): + sections = tuple(cast(list[Any] | tuple[Any, ...], value)) + else: + sections = () + + if len(sections) != len(REPRESENTATION_SECTIONS) or set(sections) != set( + REPRESENTATION_SECTIONS + ): + supported = ",".join(REPRESENTATION_SECTIONS) + raise ValueError( + "REPRESENTATION_INJECTION_ORDER must contain each supported " + + f"section exactly once: {supported}" + ) + + return cast(tuple[RepresentationSection, ...], sections) + @model_validator(mode="after") def propagate_namespace(self) -> "AppSettings": """Propagate top-level NAMESPACE to nested settings if not explicitly set.""" diff --git a/src/utils/representation.py b/src/utils/representation.py index 01e4b70a..33ee7c66 100644 --- a/src/utils/representation.py +++ b/src/utils/representation.py @@ -1,10 +1,11 @@ -from collections.abc import Sequence +from collections.abc import Callable, Iterator, Sequence from datetime import datetime from typing import Any from pydantic import BaseModel, Field, field_validator from src import models +from src.config import RepresentationSection, settings from src.utils.formatting import parse_datetime_iso # Conclusion levels whose `session_name` stamp is trustworthy enough to scope on. @@ -306,6 +307,20 @@ class ContradictionObservation(ContradictionObservationBase, ObservationMetadata ) +RepresentationObservation = ( + ExplicitObservation + | DeductiveObservation + | InductiveObservation + | ContradictionObservation +) + + +def _observation_without_timestamp(observation: RepresentationObservation) -> str: + if isinstance(observation, ExplicitObservation): + return observation.content + return observation.str_no_timestamps() + + class Representation(BaseModel): """ A Representation is a traversable and diffable map of observations. @@ -406,6 +421,29 @@ class Representation(BaseModel): self.inductive = self.inductive[-max_observations:] self.contradiction = self.contradiction[-max_observations:] + def _iter_sections( + self, + ) -> Iterator[tuple[RepresentationSection, Sequence[RepresentationObservation]]]: + sections: dict[RepresentationSection, Sequence[RepresentationObservation]] = { + "explicit": self.explicit, + "deductive": self.deductive, + "inductive": self.inductive, + "contradiction": self.contradiction, + } + for section in settings.REPRESENTATION_INJECTION_ORDER: + yield section, sections[section] + + def _format_sections( + self, format_observation: Callable[[RepresentationObservation], str] + ) -> str: + parts: list[str] = [] + for section, observations in self._iter_sections(): + parts.append(f"{section.upper()}:\n") + for index, observation in enumerate(observations, 1): + parts.append(f"{index}. {format_observation(observation)}") + parts.append("") + return "\n".join(parts) + def __str__(self) -> str: """ Format representation into a clean, readable string for LLM prompts. @@ -424,30 +462,7 @@ class Representation(BaseModel): - The user's dog is 5 years old """ - - parts: list[str] = [] - - parts.append("EXPLICIT:\n") - for i, observation in enumerate(self.explicit, 1): - parts.append(f"{i}. {observation}") - parts.append("") - - parts.append("DEDUCTIVE:\n") - for i, observation in enumerate(self.deductive, 1): - parts.append(f"{i}. {observation}") - parts.append("") - - parts.append("INDUCTIVE:\n") - for i, observation in enumerate(self.inductive, 1): - parts.append(f"{i}. {observation}") - parts.append("") - - parts.append("CONTRADICTION:\n") - for i, observation in enumerate(self.contradiction, 1): - parts.append(f"{i}. {observation}") - parts.append("") - - return "\n".join(parts) + return self._format_sections(str) def str_with_ids(self) -> str: """ @@ -468,29 +483,7 @@ class Representation(BaseModel): - id:abc123 - id:def456 """ - parts: list[str] = [] - - parts.append("EXPLICIT:\n") - for i, observation in enumerate(self.explicit, 1): - parts.append(f"{i}. {observation.str_with_id()}") - parts.append("") - - parts.append("DEDUCTIVE:\n") - for i, observation in enumerate(self.deductive, 1): - parts.append(f"{i}. {observation.str_with_id()}") - parts.append("") - - parts.append("INDUCTIVE:\n") - for i, observation in enumerate(self.inductive, 1): - parts.append(f"{i}. {observation.str_with_id()}") - parts.append("") - - parts.append("CONTRADICTION:\n") - for i, observation in enumerate(self.contradiction, 1): - parts.append(f"{i}. {observation.str_with_id()}") - parts.append("") - - return "\n".join(parts) + return self._format_sections(lambda observation: observation.str_with_id()) def str_no_timestamps(self) -> str: """ @@ -513,29 +506,7 @@ class Representation(BaseModel): - id:def456 """ - parts: list[str] = [] - - parts.append("EXPLICIT:\n") - for i, observation in enumerate(self.explicit, 1): - parts.append(f"{i}. {observation.content}") - parts.append("") - - parts.append("DEDUCTIVE:\n") - for i, observation in enumerate(self.deductive, 1): - parts.append(f"{i}. {observation.str_no_timestamps()}") - parts.append("") - - parts.append("INDUCTIVE:\n") - for i, observation in enumerate(self.inductive, 1): - parts.append(f"{i}. {observation.str_no_timestamps()}") - parts.append("") - - parts.append("CONTRADICTION:\n") - for i, observation in enumerate(self.contradiction, 1): - parts.append(f"{i}. {observation.str_no_timestamps()}") - parts.append("") - - return "\n".join(parts) + return self._format_sections(_observation_without_timestamp) def format_as_markdown(self, include_ids: bool = False) -> str: """ @@ -551,60 +522,59 @@ class Representation(BaseModel): parts: list[str] = [] - # Add explicit observations - if self.explicit: - parts.append("## Explicit Observations\n") - for obs in self.explicit: - # Don't need IDs for explicit as these are the lowest level of reasoning. - # id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" - parts.append(f"{obs}") - parts.append("") + for section, observations in self._iter_sections(): + if not observations: + continue - # Add deductive observations - if self.deductive: - parts.append("## Deductive Observations\n") - for obs in self.deductive: - id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" - timestamp = _strip_microseconds_and_timezone(obs.created_at) - parts.append(f"{id_prefix}[{timestamp}] {obs.conclusion}") - if obs.premises: - parts.append(" Premises:") - for premise in obs.premises: - parts.append(f" - {premise}") + if section == "explicit": + parts.append("## Explicit Observations\n") + for obs in self.explicit: + # IDs are unnecessary for the lowest reasoning level. + parts.append(f"{obs}") parts.append("") - parts.append("") - # Add inductive observations - if self.inductive: - parts.append("## Inductive Observations\n") - for obs in self.inductive: - id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" - parts.append( - f"{id_prefix} **Pattern** [{obs.confidence}]: {obs.conclusion}" - ) - if obs.pattern_type: - parts.append(f" **Type**: {obs.pattern_type}") - if obs.sources: - parts.append(" **Sources**:") - for source in obs.sources[:5]: - parts.append(f" - {source}") - if len(obs.sources) > 5: - parts.append(f" - ... and {len(obs.sources) - 5} more") + elif section == "deductive": + parts.append("## Deductive Observations\n") + for obs in self.deductive: + id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" + timestamp = _strip_microseconds_and_timezone(obs.created_at) + parts.append(f"{id_prefix}[{timestamp}] {obs.conclusion}") + if obs.premises: + parts.append(" Premises:") + for premise in obs.premises: + parts.append(f" - {premise}") + parts.append("") parts.append("") - parts.append("") - # Add contradiction observations - if self.contradiction: - parts.append("## Contradictions\n") - for obs in self.contradiction: - id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" - parts.append(f"{id_prefix} **CONTRADICTION**: {obs.content}") - if obs.sources: - parts.append(" **Conflicting statements**:") - for source in obs.sources: - parts.append(f" - {source}") + elif section == "inductive": + parts.append("## Inductive Observations\n") + for obs in self.inductive: + id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" + parts.append( + f"{id_prefix} **Pattern** [{obs.confidence}]: {obs.conclusion}" + ) + if obs.pattern_type: + parts.append(f" **Type**: {obs.pattern_type}") + if obs.sources: + parts.append(" **Sources**:") + for source in obs.sources[:5]: + parts.append(f" - {source}") + if len(obs.sources) > 5: + parts.append(f" - ... and {len(obs.sources) - 5} more") + parts.append("") + parts.append("") + + else: + parts.append("## Contradictions\n") + for obs in self.contradiction: + id_prefix = f"[id:{obs.id}] " if include_ids and obs.id else "" + parts.append(f"{id_prefix} **CONTRADICTION**: {obs.content}") + if obs.sources: + parts.append(" **Conflicting statements**:") + for source in obs.sources: + parts.append(f" - {source}") + parts.append("") parts.append("") - parts.append("") return "\n".join(parts) diff --git a/tests/utils/test_representation_order.py b/tests/utils/test_representation_order.py new file mode 100644 index 00000000..3fa898d7 --- /dev/null +++ b/tests/utils/test_representation_order.py @@ -0,0 +1,288 @@ +import asyncio +from collections.abc import Callable +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest + +from src import crud +from src.config import AppSettings +from src.embedding_client import embedding_client +from src.utils import agent_tools +from src.utils import representation as representation_module +from src.utils.agent_tools import ToolContext +from src.utils.representation import ( + ContradictionObservation, + DeductiveObservation, + ExplicitObservation, + InductiveObservation, + Representation, +) +from src.utils.types import ToolResult + +DEFAULT_ORDER = ("explicit", "deductive", "inductive", "contradiction") +PATTERNS_FIRST_ORDER = ("inductive", "explicit", "deductive", "contradiction") + + +def _full_representation() -> Representation: + created_at = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + return Representation( + explicit=[ + ExplicitObservation( + id="exp-1", + content="explicit fact", + created_at=created_at, + message_ids=[1], + ) + ], + deductive=[ + DeductiveObservation( + id="ded-1", + conclusion="deductive fact", + premises=["explicit fact"], + created_at=created_at, + message_ids=[1], + ) + ], + inductive=[ + InductiveObservation( + id="ind-1", + conclusion="inductive pattern", + confidence="high", + pattern_type="preference", + sources=["explicit fact"], + created_at=created_at, + message_ids=[1], + ) + ], + contradiction=[ + ContradictionObservation( + id="con-1", + content="conflicting fact", + sources=["a", "b"], + created_at=created_at, + message_ids=[1], + ) + ], + ) + + +def _assert_headers_in_order(rendered: str, headers: tuple[str, ...]) -> None: + positions = [rendered.index(header) for header in headers] + assert positions == sorted(positions) + + +def _render_with_ids(representation: Representation) -> str: + return representation.str_with_ids() + + +def _render_without_timestamps(representation: Representation) -> str: + return representation.str_no_timestamps() + + +def _render_as_markdown(representation: Representation) -> str: + return representation.format_as_markdown(include_ids=True) + + +def test_representation_injection_order_defaults_to_current_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("REPRESENTATION_INJECTION_ORDER", raising=False) + + assert AppSettings().REPRESENTATION_INJECTION_ORDER == DEFAULT_ORDER + + +def test_representation_injection_order_accepts_comma_separated_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "REPRESENTATION_INJECTION_ORDER", + "inductive, explicit, deductive, contradiction", + ) + + assert AppSettings().REPRESENTATION_INJECTION_ORDER == PATTERNS_FIRST_ORDER + + +@pytest.mark.parametrize( + "configured_order", + [ + "explicit,deductive,inductive", + "explicit,deductive,inductive,explicit", + "explicit,deductive,inductive,unknown", + ], +) +def test_representation_injection_order_rejects_invalid_permutations( + monkeypatch: pytest.MonkeyPatch, configured_order: str +) -> None: + monkeypatch.setenv("REPRESENTATION_INJECTION_ORDER", configured_order) + + with pytest.raises( + ValueError, + match="REPRESENTATION_INJECTION_ORDER must contain each supported section exactly once", + ): + AppSettings() + + +def test_default_rendering_is_byte_for_byte_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + representation_module, + "settings", + AppSettings(REPRESENTATION_INJECTION_ORDER=DEFAULT_ORDER), + raising=False, + ) + representation = _full_representation() + + assert str(representation) == ( + "EXPLICIT:\n\n" + "1. [2025-01-02 03:04:05] explicit fact\n\n" + "DEDUCTIVE:\n\n" + "1. [2025-01-02 03:04:05] deductive fact\n" + " - explicit fact\n\n" + "INDUCTIVE:\n\n" + "1. [2025-01-02 03:04:05] [high] inductive pattern\n" + " - explicit fact\n\n" + "CONTRADICTION:\n\n" + "1. [2025-01-02 03:04:05] CONTRADICTION: conflicting fact\n" + " - a\n" + " - b\n" + ) + assert representation.str_with_ids() == ( + "EXPLICIT:\n\n" + "1. [id:exp-1] [2025-01-02 03:04:05] explicit fact\n\n" + "DEDUCTIVE:\n\n" + "1. [id:ded-1] [2025-01-02 03:04:05] deductive fact\n" + " - explicit fact\n\n" + "INDUCTIVE:\n\n" + "1. [id:ind-1] [2025-01-02 03:04:05] [high] inductive pattern\n" + " - explicit fact\n\n" + "CONTRADICTION:\n\n" + "1. [id:con-1] [2025-01-02 03:04:05] CONTRADICTION: conflicting fact\n" + " - a\n" + " - b\n" + ) + assert representation.str_no_timestamps() == ( + "EXPLICIT:\n\n" + "1. explicit fact\n\n" + "DEDUCTIVE:\n\n" + "1. deductive fact\n" + " - explicit fact\n\n" + "INDUCTIVE:\n\n" + "1. [high] inductive pattern\n" + " - explicit fact\n\n" + "CONTRADICTION:\n\n" + "1. CONTRADICTION: conflicting fact\n" + " - a\n" + " - b\n" + ) + assert representation.format_as_markdown(include_ids=True) == ( + "## Explicit Observations\n\n" + "[2025-01-02 03:04:05] explicit fact\n\n" + "## Deductive Observations\n\n" + "[id:ded-1] [2025-01-02 03:04:05] deductive fact\n" + " Premises:\n" + " - explicit fact\n\n\n" + "## Inductive Observations\n\n" + "[id:ind-1] **Pattern** [high]: inductive pattern\n" + " **Type**: preference\n" + " **Sources**:\n" + " - explicit fact\n\n\n" + "## Contradictions\n\n" + "[id:con-1] **CONTRADICTION**: conflicting fact\n" + " **Conflicting statements**:\n" + " - a\n" + " - b\n\n" + ) + + +@pytest.mark.parametrize( + ("renderer", "ordered_headers"), + [ + ( + str, + ("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"), + ), + ( + _render_with_ids, + ("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"), + ), + ( + _render_without_timestamps, + ("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"), + ), + ( + _render_as_markdown, + ( + "## Inductive Observations", + "## Explicit Observations", + "## Deductive Observations", + "## Contradictions", + ), + ), + ], +) +def test_all_renderers_use_the_configured_section_order( + monkeypatch: pytest.MonkeyPatch, + renderer: Callable[[Representation], str], + ordered_headers: tuple[str, ...], +) -> None: + monkeypatch.setattr( + representation_module, + "settings", + AppSettings(REPRESENTATION_INJECTION_ORDER=PATTERNS_FIRST_ORDER), + raising=False, + ) + + rendered = renderer(_full_representation()) + + _assert_headers_in_order(rendered, ordered_headers) + + +@pytest.mark.asyncio +async def test_search_memory_prompt_uses_the_configured_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + representation = _full_representation() + monkeypatch.setattr( + representation_module, + "settings", + AppSettings(REPRESENTATION_INJECTION_ORDER=PATTERNS_FIRST_ORDER), + raising=False, + ) + monkeypatch.setattr( + embedding_client, + "embed", + AsyncMock(return_value=[0.1]), + ) + monkeypatch.setattr( + crud, + "query_documents", + AsyncMock(return_value=[object()]), + ) + + def return_representation(_documents: object) -> Representation: + return representation + + monkeypatch.setattr(Representation, "from_documents", return_representation) + context = ToolContext( + workspace_name="workspace", + observer="observer", + observed="observed", + session_name=None, + current_messages=None, + include_observation_ids=False, + history_token_limit=8192, + db_lock=asyncio.Lock(), + ) + + result = await agent_tools._handle_search_memory( # pyright: ignore[reportPrivateUsage] + context, + {"query": "patterns", "top_k": 10}, + ) + + assert isinstance(result, ToolResult) + _assert_headers_in_order( + result.content, + ("INDUCTIVE:", "EXPLICIT:", "DEDUCTIVE:", "CONTRADICTION:"), + ) From 7d03594fec62abb98db9230074a9036ffae39eb1 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 21 Aug 2026 02:32:29 +0800 Subject: [PATCH 2/2] docs(config): document representation order helpers --- src/config.py | 11 +++++++++++ src/utils/representation.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/config.py b/src/config.py index 92548c7b..15c23540 100644 --- a/src/config.py +++ b/src/config.py @@ -1585,6 +1585,17 @@ class AppSettings(HonchoSettings): def validate_representation_injection_order( cls, value: Any ) -> tuple[RepresentationSection, ...]: + """Normalize and validate the representation section order. + + Args: + value: Comma-separated string or sequence of supported section names. + + Returns: + The validated section order as a tuple. + + Raises: + ValueError: If the value is not an exact permutation of all sections. + """ sections: tuple[Any, ...] if isinstance(value, str): sections = tuple(section.strip() for section in value.split(",")) diff --git a/src/utils/representation.py b/src/utils/representation.py index 33ee7c66..e6abdbaf 100644 --- a/src/utils/representation.py +++ b/src/utils/representation.py @@ -316,6 +316,14 @@ RepresentationObservation = ( def _observation_without_timestamp(observation: RepresentationObservation) -> str: + """Format an observation without timestamp metadata. + + Args: + observation: Observation to format. + + Returns: + Raw content for an explicit observation, otherwise timestamp-free text. + """ if isinstance(observation, ExplicitObservation): return observation.content return observation.str_no_timestamps() @@ -424,6 +432,11 @@ class Representation(BaseModel): def _iter_sections( self, ) -> Iterator[tuple[RepresentationSection, Sequence[RepresentationObservation]]]: + """Yield observation sections in the configured injection order. + + Yields: + Pairs containing a section name and its observation sequence. + """ sections: dict[RepresentationSection, Sequence[RepresentationObservation]] = { "explicit": self.explicit, "deductive": self.deductive, @@ -436,6 +449,14 @@ class Representation(BaseModel): def _format_sections( self, format_observation: Callable[[RepresentationObservation], str] ) -> str: + """Format every section in configured order, including empty headers. + + Args: + format_observation: Callable that renders one observation. + + Returns: + Newline-delimited sections with observations numbered per section. + """ parts: list[str] = [] for section, observations in self._iter_sections(): parts.append(f"{section.upper()}:\n")