Files
2026-07-16 13:36:11 +02:00

484 lines
18 KiB
Python

import logging
from enum import Enum, auto
from typing import Any, Protocol
from private_gpt.components.readers.nodes.section_node import SectionNode
from private_gpt.components.readers.nodes.tree_node import TreeNode
from private_gpt.settings.settings import settings
debug_mode = settings().server.debug_mode
logger = logging.getLogger(__name__)
class ExpansionResult(Enum):
CONTINUE = auto() # Can continue expanding other parts
STOP = auto() # Stop further expansion due to token limit
ROLLBACK = auto() # Skip this node, but can continue with others
def is_success(self) -> bool:
return self in [ExpansionResult.CONTINUE]
def is_failure(self) -> bool:
return self in [ExpansionResult.STOP, ExpansionResult.ROLLBACK]
class ExpansionDirection(Enum):
DOWNWARDS = auto()
HORIZONTALLY = auto()
UPWARDS = auto()
class ExpansionDirectionProtocol(Protocol):
def __call__(self, current_node: TreeNode, **kwargs: Any) -> ExpansionResult: ...
class DocumentTreeExpander:
"""Expands a document tree while respecting token limits.
This class implements a strategy to expand a document tree in multiple directions
(downwards, horizontally, and upwards) while ensuring the total token count stays
within specified limits. It uses different failure handling strategies for different
expansion phases.
"""
debug: bool = debug_mode
node: TreeNode
max_tokens: int
processed_nodes_ids: dict[ExpansionDirection, set[str]]
result_nodes_ids: set[str]
_current_success_result: ExpansionResult = ExpansionResult.CONTINUE
_current_failure_result: ExpansionResult = ExpansionResult.STOP
_remaining_tokens: int
_token_counts: dict[str, int]
huge_jump = 10000000
large_jump = 100000
small_jump = 1
def __init__(
self,
node: TreeNode,
max_tokens: int,
processed_nodes_ids: dict[ExpansionDirection, set[str]] | None = None,
) -> None:
"""Initialize the document tree expander.
Args:
node: The node of the tree to expand
max_tokens: Maximum number of tokens to expand
processed_nodes_ids: Dictionary mapping direction to sets of IDs
"""
self.node = node
self.max_tokens = max_tokens
self._remaining_tokens = max_tokens
self.processed_nodes_ids = processed_nodes_ids or {
ExpansionDirection.DOWNWARDS: set(),
ExpansionDirection.HORIZONTALLY: set(),
ExpansionDirection.UPWARDS: set(),
}
self.result_nodes_ids = set()
self._token_counts = {}
def fill_window(self) -> tuple[set[str], int]:
"""Main method to expand the document tree within token constraints.
The expansion happens in two phases:
1. Downward expansion with STOP
on failure to preserve partial subtrees.
2. Horizontal and upward expansion with ROLLBACK
on failure for complete subtrees.
Returns:
Set of expanded node IDs
"""
# Start expanding recursively in all directions
result = self._expand_recursively(
current_node=self.node,
include_downwards=True,
include_horizontally=True,
include_upwards=True,
)
token_count = self.max_tokens - self._remaining_tokens
if result.is_failure():
return self.result_nodes_ids, token_count
# Return the IDs of the expanded nodes
logger.debug(f"Expanded nodes: {self.result_nodes_ids}")
return self.result_nodes_ids, token_count
def _get_token_count_by_id(self, node_id: str) -> int | None:
"""Calculate the token count of a node and its subtree (if applicable).
Args:
node_id: The node ID to count tokens for
include_subtree: Whether to include tokens from all descendant nodes
Returns:
Total token count
"""
return self._token_counts.get(node_id)
def _get_token_count(self, node: TreeNode, include_subtree: bool) -> int:
"""Calculate the token count of a node and its subtree (if applicable).
Args:
node: The node to count tokens for
include_subtree: Whether to include tokens from all descendant nodes
Returns:
Total token count
"""
def get_key() -> str:
suffix = "-subtree" if include_subtree else None
return f"{node.id_}{suffix}"
key = get_key()
if key in self._token_counts:
return self._token_counts[key]
count = node.token_count if not include_subtree else node.get_sum_token_count()
self._token_counts[key] = count
return count
def _is_already_processed(
self, node: TreeNode, direction: ExpansionDirection | None
) -> bool:
"""Check if a node has already been processed in a specific direction.
Args:
node: Node to check
direction: Direction to check for
Returns:
True if the node has been processed, False otherwise
"""
if direction is not None:
return node.id_ in self.processed_nodes_ids[direction]
all_full_processed = self.processed_nodes_ids[
ExpansionDirection.DOWNWARDS
].intersection(
self.processed_nodes_ids[ExpansionDirection.HORIZONTALLY],
self.processed_nodes_ids[ExpansionDirection.UPWARDS],
)
return node.id_ in all_full_processed
def _expand_direction(
self,
current_node: TreeNode,
expand_func: ExpansionDirectionProtocol,
direction: ExpansionDirection,
**kwargs: Any,
) -> ExpansionResult:
"""Expand a node in a specific direction and process the result.
Args:
current_node: Node to expand
expand_func: Function implementing the expansion strategy
direction: Direction of expansion
kwargs: Additional arguments for the expansion function
Returns:
Result of the expansion attempt
"""
reverse = kwargs.pop("reverse", False)
# Only add to results immediately if moving right
if not reverse:
self.result_nodes_ids.add(current_node.id_)
# Step 2: Expand the node in the specified direction
result = expand_func(current_node, reverse=reverse, **kwargs)
# Step 1: Mark the node as processed in the specified direction
self.processed_nodes_ids[direction].add(current_node.id_)
# Only add to results if all subtrees are processed
if reverse:
self.result_nodes_ids.add(current_node.id_)
# Step 3: Process the result
if result == ExpansionResult.STOP:
logger.debug(f"Stopped at node: {current_node}")
# If we're moving left and hit a stop, we should remove this node
# if not all children are in the result set
all_children = [node.id_ for node in current_node.children]
if reverse and any(
node_id not in self.result_nodes_ids for node_id in all_children
):
self.result_nodes_ids.discard(current_node.id_)
elif result == ExpansionResult.CONTINUE:
logger.debug(f"Expanded node: {current_node}")
return result
def _expand_recursively(
self,
current_node: TreeNode,
include_downwards: bool = False,
include_horizontally: bool = False,
include_upwards: bool = False,
**kwargs: Any,
) -> ExpansionResult:
"""Recursive method to expand nodes while respecting token constraints.
Args:
current_node: Node to potentially expand
include_downwards: Flag to expand children
include_horizontally: Flag to expand siblings
include_upwards: Flag to expand parents
kwargs: Additional arguments to pass to the expansion functions
Returns:
Result indicating if expansion was successful
"""
# Check if node has been processed in all directions
if self._is_already_processed(current_node, direction=None):
logger.debug(f"Skipping already processed node: {current_node}")
return self._current_success_result
# Check token limit
potential_tokens = self._get_token_count(current_node, include_subtree=False)
if potential_tokens > self._remaining_tokens:
logger.debug(f"Exceeded token limit at node: {current_node}")
return self._current_failure_result
# Update remaining tokens before expansion
self._remaining_tokens -= potential_tokens
# First, try to expand downwards (depth-first)
if include_downwards:
logger.debug(f"Expanding node in direction downwards: {current_node}")
expansion_success = self._expand_direction(
current_node=current_node,
expand_func=self._expand_downwards,
direction=ExpansionDirection.DOWNWARDS,
**kwargs,
)
if expansion_success.is_failure():
return expansion_success
if include_horizontally:
logger.debug(f"Expanding node in direction horizontally: {current_node}")
expansion_success = self._expand_direction(
current_node=current_node,
expand_func=self._expand_horizontally,
direction=ExpansionDirection.HORIZONTALLY,
**kwargs,
)
if expansion_success.is_failure():
return expansion_success
# If horizontal expansion is complete, try expanding upwards
if include_upwards:
logger.debug(f"Expanding node in direction upwards: {current_node}")
expansion_success = self._expand_direction(
current_node=current_node,
expand_func=self._expand_upwards,
direction=ExpansionDirection.UPWARDS,
**kwargs,
)
if expansion_success.is_failure():
return expansion_success
return self._current_success_result
def _expand_downwards(
self, current_node: TreeNode, **kwargs: Any
) -> ExpansionResult:
"""Expand node's children depth-first.
Args:
current_node: Node whose children to expand
kwargs: Additional expansion parameters
Returns:
Result of the downward expansion
"""
if self._is_already_processed(current_node, ExpansionDirection.DOWNWARDS):
logger.debug(f"Skipping downwards expansion for node: {current_node}")
return self._current_success_result
reverse = kwargs.get("reverse", False)
children = reversed(current_node.children) if reverse else current_node.children
for child in children:
if self._is_already_processed(child, ExpansionDirection.DOWNWARDS):
continue
# Check if adding this child would exceed token limit
potential_tokens = self._get_token_count(
child,
include_subtree=True,
)
if potential_tokens > self._remaining_tokens:
return self._current_failure_result
# Attempt to expand child
child_expansion = self._expand_recursively(
current_node=child,
include_downwards=True,
include_horizontally=True,
include_upwards=False,
**kwargs,
)
if child_expansion.is_failure():
return child_expansion
return self._current_success_result
def _expand_horizontally(
self, current_node: TreeNode, **kwargs: dict[str, Any]
) -> ExpansionResult:
"""Expand sibling nodes with weighted ping-pong strategy."""
if not current_node.parent:
logger.debug(f"Skipping horizontal expansion for root node: {current_node}")
return self._current_success_result
if self._is_already_processed(
current_node.parent, ExpansionDirection.HORIZONTALLY
):
logger.debug(
f"Skipping horizontal expansion for node: {current_node.parent}"
)
return self._current_success_result
# Calculate sibling weights based on node types and content
siblings = current_node.parent.children
current_index = siblings.index(current_node)
# Ping-pong expansion strategy
right_candidates = siblings[current_index + 1 :]
left_candidates = siblings[:current_index][::-1]
# Normalize the lengths of right and left candidates
max_length = max(len(right_candidates), len(left_candidates))
normalized_right = right_candidates + [None] * (
max_length - len(right_candidates)
)
normalized_left = left_candidates + [None] * (max_length - len(left_candidates))
# Last successfully expanded nodes in each direction
right_expanded, left_expanded = current_node, current_node
for right, left in zip(normalized_right, normalized_left, strict=False):
# Calculate candidate list
candidates: list[TreeNode] = [
n
for n in [right, left]
if n
and not self._is_already_processed(n, ExpansionDirection.HORIZONTALLY)
and n not in (right_expanded, left_expanded)
]
if not candidates:
continue
# Sort candidates considering:
# 1. Weight (lower is better)
# 2. Token count (lower is better)
# 3. Distance from current node (lower is better)
# 4. Move before to righter than left
candidates.sort(
key=lambda sibling: (
self._calculate_node_weight(current_node, sibling),
self._get_token_count(sibling, include_subtree=True),
abs(siblings.index(sibling) - current_index),
siblings.index(sibling) > current_index,
)
)
results: list[ExpansionResult] = []
for sibling in candidates:
# Only expand downwards to avoid infinite loops
expanded_siblings = self._expand_recursively(
sibling,
include_downwards=True,
include_horizontally=False,
include_upwards=False,
reverse=kwargs.pop("reverse", False) or sibling == left,
**kwargs,
)
results.append(expanded_siblings)
if expanded_siblings.is_failure():
continue
# Update references for ping-pong expansion
right_expanded, left_expanded = (
sibling if sibling == right else right_expanded,
sibling if sibling == left else left_expanded,
)
# If all candidates failed, we stop the expansion
if all(result.is_failure() for result in results):
return self._current_failure_result
# If all candidates succeeded, we continue the expansion
return self._current_success_result
def _expand_upwards(self, current_node: TreeNode, **kwargs: Any) -> ExpansionResult:
"""Attempt to expand upwards through parent nodes.
Args:
current_node: Node whose parent to expand
kwargs: Additional expansion parameters
Returns:
Result of the upward expansion
"""
parent = current_node.parent
if not parent:
# Stop if we reach the root node
logger.debug(f"Reached root node: {current_node}")
return self._current_failure_result
if self._is_already_processed(parent, ExpansionDirection.UPWARDS):
logger.debug(f"Skipping upwards expansion for node: {parent}")
return self._current_success_result
# Expand parent node
result = self._expand_recursively(
current_node=parent,
include_downwards=True,
include_horizontally=True,
include_upwards=True,
**kwargs,
)
return result
def _calculate_node_weight(self, from_node: TreeNode, to_node: TreeNode) -> float:
"""Calculate weight for node expansion based on type and subtree.
Args:
from_node: Node from which to expand
to_node: Node to which to expand
Returns:
Weight multiplier for node expansion
"""
from_section = from_node.isinstance(SectionNode)
from_content = not from_section
to_section = to_node.isinstance(SectionNode)
to_content = not to_section
distance = self.small_jump
if from_section and to_section:
# Moving horizontally between sections
distance = self.large_jump
elif from_section and to_content:
# This case should be rare, plain content at the root
# followed by a section since we are in the horizontal scenario
distance = self.small_jump
elif from_content and to_content:
# Horizontal movement within the content
distance = self.small_jump
elif from_content and to_section:
# Moving to a subsection within the content
distance = self.small_jump
return distance