feat: implement semantic chunking context strategy for ACMS advanced context assembly #10663

Closed
HAL9000 wants to merge 2 commits from feat/acms-semantic-chunking-context-strategy into master
3 changed files with 586 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
Feature: Semantic chunking context strategy for ACMS
As a context assembly system
I want to split files into semantic chunks
So that I can select individual chunks with relevance scoring
Background:
Given a semantic chunking strategy is initialized
Scenario: Chunk Python file into functions and classes
Given a Python file with functions and classes
When I chunk the Python file
Then I should get chunks for each function
And I should get chunks for each class
And I should get chunks for each method
Scenario: Chunk Markdown file into sections
Given a Markdown file with multiple sections
When I chunk the Markdown file
Then I should get chunks for each section
And each chunk should have the section title as name
Scenario: Handle Python file with syntax errors
Given a Python file with syntax errors
When I chunk the Python file
Then I should get a single chunk for the whole file
Scenario: Score chunk relevance
Given a list of semantic chunks
When I score the chunks for relevance
Then smaller chunks should have higher relevance scores
And chunks matching a query should have higher scores
Scenario: Select chunks within budget
Given a list of semantic chunks
And a context budget of 4096 characters
When I select chunks within the budget
Then only chunks that fit should be selected
And chunks should be sorted by relevance
Scenario: Convert chunks to context fragments
Given a list of semantic chunks
When I convert them to context fragments
Then each fragment should have the chunk content
And each fragment should have metadata about the chunk type
@@ -0,0 +1,237 @@
"""Step definitions for semantic chunking BDD tests."""
from behave import given, then, when
from cleveragents.domain.models.acms.semantic_chunking import (
ChunkRelevanceScorer,
SemanticChunk,
SemanticChunkingStrategy,
)
@given("a semantic chunking strategy is initialized")
def step_init_strategy(context):
"""Initialize a semantic chunking strategy."""
context.strategy = SemanticChunkingStrategy()
@given("a Python file with functions and classes")
def step_python_file_with_functions_and_classes(context):
"""Create a Python file with functions and classes."""
context.python_content = '''
def hello_world():
"""A simple function."""
return "Hello, World!"
class MyClass:
"""A simple class."""
def __init__(self):
"""Initialize the class."""
self.value = 42
def get_value(self):
"""Get the value."""
return self.value
'''
context.python_file = "test.py"
@when("I chunk the Python file")
def step_chunk_python_file(context):
"""Chunk the Python file."""
context.chunks = context.strategy.chunk_file(
context.python_file, context.python_content
)
@then("I should get chunks for each function")
def step_check_function_chunks(context):
"""Check that function chunks were created."""
function_chunks = [c for c in context.chunks if c.chunk_type == "function"]
assert len(function_chunks) > 0, "No function chunks found"
assert any(c.name == "hello_world" for c in function_chunks)
@then("I should get chunks for each class")
def step_check_class_chunks(context):
"""Check that class chunks were created."""
class_chunks = [c for c in context.chunks if c.chunk_type == "class"]
assert len(class_chunks) > 0, "No class chunks found"
assert any(c.name == "MyClass" for c in class_chunks)
@then("I should get chunks for each method")
def step_check_method_chunks(context):
"""Check that method chunks were created."""
method_chunks = [c for c in context.chunks if c.chunk_type == "method"]
assert len(method_chunks) > 0, "No method chunks found"
assert any("__init__" in c.name for c in method_chunks)
assert any("get_value" in c.name for c in method_chunks)
@given("a Markdown file with multiple sections")
def step_markdown_file_with_sections(context):
"""Create a Markdown file with multiple sections."""
context.markdown_content = '''# Introduction
This is the introduction section.
## Getting Started
This section explains how to get started.
### Installation
Install the package using pip.
## Usage
This section explains how to use the package.
### Examples
Here are some examples.
'''
context.markdown_file = "test.md"
@when("I chunk the Markdown file")
def step_chunk_markdown_file(context):
"""Chunk the Markdown file."""
context.chunks = context.strategy.chunk_file(
context.markdown_file, context.markdown_content
)
@then("I should get chunks for each section")
def step_check_section_chunks(context):
"""Check that section chunks were created."""
section_chunks = [c for c in context.chunks if c.chunk_type == "section"]
assert len(section_chunks) > 0, "No section chunks found"
@then("each chunk should have the section title as name")
def step_check_section_names(context):
"""Check that section chunks have proper names."""
section_chunks = [c for c in context.chunks if c.chunk_type == "section"]
names = [c.name for c in section_chunks]
assert "Introduction" in names or any("Introduction" in n for n in names)
@given("a Python file with syntax errors")
def step_python_file_with_syntax_errors(context):
"""Create a Python file with syntax errors."""
context.python_content = '''
def broken_function(
# Missing closing parenthesis
return "This won't parse"
'''
context.python_file = "broken.py"
@then("I should get a single chunk for the whole file")
def step_check_single_chunk(context):
"""Check that a single chunk was created for the whole file."""
assert len(context.chunks) == 1
assert context.chunks[0].chunk_type == "file"
@given("a list of semantic chunks")
def step_create_semantic_chunks(context):
"""Create a list of semantic chunks."""
context.chunks = [
SemanticChunk(
file_path="test.py",
chunk_type="function",
name="small_func",
start_line=1,
end_line=3,
content="def small_func():\n return 42\n",
),
SemanticChunk(
file_path="test.py",
chunk_type="function",
name="large_func",
start_line=5,
end_line=50,
content="def large_func():\n" + " x = 1\n" * 45,
),
]
@when("I score the chunks for relevance")
def step_score_chunks(context):
"""Score the chunks for relevance."""
context.scorer = ChunkRelevanceScorer()
context.scored_chunks = [
(chunk, context.scorer.score(chunk, query=None, context_budget=4096))
for chunk in context.chunks
]
@then("smaller chunks should have higher relevance scores")
def step_check_smaller_chunks_higher_score(context):
"""Check that smaller chunks have higher scores."""
small_chunk_score = next(
score for chunk, score in context.scored_chunks if chunk.name == "small_func"
)
large_chunk_score = next(
score for chunk, score in context.scored_chunks if chunk.name == "large_func"
)
assert small_chunk_score > large_chunk_score
@then("chunks matching a query should have higher scores")
def step_check_query_matching_score(context):
"""Check that chunks matching a query have higher scores."""
scorer = ChunkRelevanceScorer()
score_without_query = scorer.score(context.chunks[0], query=None)
score_with_query = scorer.score(context.chunks[0], query="small_func")
assert score_with_query > score_without_query
@given("a context budget of {budget:d} characters")
def step_set_context_budget(context, budget):
"""Set the context budget."""
context.context_budget = budget
@when("I select chunks within the budget")
def step_select_chunks_within_budget(context):
"""Select chunks within the budget."""
context.selected_chunks = context.strategy.select_chunks(
context.chunks, context_budget=context.context_budget
)
@then("only chunks that fit should be selected")
def step_check_chunks_fit_budget(context):
"""Check that selected chunks fit within the budget."""
total_size = sum(len(chunk.content) for chunk in context.selected_chunks)
assert total_size <= context.context_budget
@then("chunks should be sorted by relevance")
def step_check_chunks_sorted_by_relevance(context):
"""Check that chunks are sorted by relevance."""
scores = [chunk.relevance_score for chunk in context.selected_chunks]
assert scores == sorted(scores, reverse=True)
@when("I convert them to context fragments")
def step_convert_to_fragments(context):
"""Convert chunks to context fragments."""
context.fragments = context.strategy.to_context_fragments(context.chunks)
@then("each fragment should have the chunk content")
def step_check_fragment_content(context):
"""Check that fragments have the chunk content."""
for fragment, chunk in zip(context.fragments, context.chunks, strict=True):
assert fragment.content == chunk.content
@then("each fragment should have metadata about the chunk type")
def step_check_fragment_metadata(context):
"""Check that fragments have metadata about the chunk type."""
for fragment, chunk in zip(context.fragments, context.chunks, strict=True):
assert "chunk_type" in fragment.metadata
assert fragment.metadata["chunk_type"] == chunk.chunk_type
assert "chunk_name" in fragment.metadata
assert fragment.metadata["chunk_name"] == chunk.name
@@ -0,0 +1,305 @@
"""Semantic chunking strategy for ACMS context assembly."""
import ast
import re
from dataclasses import dataclass
from pathlib import Path
from cleveragents.domain.models.core.context_fragment import (
ContextFragment,
FragmentProvenance,
)
@dataclass
class SemanticChunk:
"""Represents a semantically meaningful chunk of code or text."""
file_path: str
chunk_type: str
name: str
start_line: int
end_line: int
content: str
relevance_score: float = 0.5
class PythonSemanticChunker:
"""Chunks Python files into semantic units using AST analysis."""
def __init__(self) -> None:
"""Initialize the Python semantic chunker."""
self.chunks: list[SemanticChunk] = []
def chunk(self, file_path: str, content: str) -> list[SemanticChunk]:
"""Chunk a Python file into semantic units."""
self.chunks = []
try:
tree = ast.parse(content)
except SyntaxError:
return [
SemanticChunk(
file_path=file_path,
chunk_type="file",
name=Path(file_path).stem,
start_line=1,
end_line=len(content.splitlines()),
content=content,
relevance_score=0.5,
)
]
lines = content.splitlines(keepends=True)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
self._extract_function_chunk(node, lines, file_path)
elif isinstance(node, ast.ClassDef):
self._extract_class_chunk(node, lines, file_path)
if not self.chunks:
return [
SemanticChunk(
file_path=file_path,
chunk_type="file",
name=Path(file_path).stem,
start_line=1,
end_line=len(lines),
content=content,
relevance_score=0.5,
)
]
return self.chunks
def _extract_function_chunk(
self, node: ast.FunctionDef, lines: list[str], file_path: str
) -> None:
"""Extract a function as a semantic chunk."""
start_line = node.lineno
end_line = node.end_lineno or node.lineno
chunk_content = "".join(lines[start_line - 1 : end_line])
self.chunks.append(
SemanticChunk(
file_path=file_path,
chunk_type="function",
name=node.name,
start_line=start_line,
end_line=end_line,
content=chunk_content,
relevance_score=0.5,
)
)
def _extract_class_chunk(
self, node: ast.ClassDef, lines: list[str], file_path: str
) -> None:
"""Extract a class and its methods as semantic chunks."""
start_line = node.lineno
end_line = node.end_lineno or node.lineno
chunk_content = "".join(lines[start_line - 1 : end_line])
self.chunks.append(
SemanticChunk(
file_path=file_path,
chunk_type="class",
name=node.name,
start_line=start_line,
end_line=end_line,
content=chunk_content,
relevance_score=0.5,
)
)
for item in node.body:
if isinstance(item, ast.FunctionDef):
method_start = item.lineno
method_end = item.end_lineno or item.lineno
method_content = "".join(lines[method_start - 1 : method_end])
self.chunks.append(
SemanticChunk(
file_path=file_path,
chunk_type="method",
name=f"{node.name}.{item.name}",
start_line=method_start,
end_line=method_end,
content=method_content,
relevance_score=0.5,
)
)
class MarkdownSemanticChunker:
"""Chunks Markdown files into semantic units based on sections."""
def __init__(self) -> None:
"""Initialize the Markdown semantic chunker."""
self.chunks: list[SemanticChunk] = []
def chunk(self, file_path: str, content: str) -> list[SemanticChunk]:
"""Chunk a Markdown file into semantic units."""
self.chunks = []
lines = content.splitlines(keepends=True)
header_pattern = re.compile(r"^(#{1,6})\s+(.+)$")
headers: list[tuple[int, int, str]] = []
for i, line in enumerate(lines):
match = header_pattern.match(line)
if match:
_level = len(match.group(1))
title = match.group(2).strip()
headers.append((i, _level, title))
if not headers:
return [
SemanticChunk(
file_path=file_path,
chunk_type="file",
name=Path(file_path).stem,
start_line=1,
end_line=len(lines),
content=content,
relevance_score=0.5,
)
]
for i, (header_line, _level, title) in enumerate(headers):
end_line = headers[i + 1][0] if i + 1 < len(headers) else len(lines)
chunk_content = "".join(lines[header_line:end_line])
self.chunks.append(
SemanticChunk(
file_path=file_path,
chunk_type="section",
name=title,
start_line=header_line + 1,
end_line=end_line,
content=chunk_content,
relevance_score=0.5,
)
)
return self.chunks
class ChunkRelevanceScorer:
"""Scores the relevance of chunks for context selection."""
def __init__(self) -> None:
"""Initialize the chunk relevance scorer."""
def score(
self,
chunk: SemanticChunk,
query: str | None = None,
context_budget: int = 4096,
) -> float:
"""Score the relevance of a chunk."""
score = 0.5
chunk_size = len(chunk.content)
if chunk_size < context_budget * 0.1:
score += 0.2
elif chunk_size < context_budget * 0.5:
score += 0.1
if chunk.chunk_type in ("class", "function", "method"):
score += 0.1
if query and query.lower() in chunk.name.lower():
score += 0.3
return min(1.0, max(0.0, score))
class SemanticChunkingStrategy:
"""Context strategy that uses semantic chunking for context assembly."""
def __init__(self) -> None:
"""Initialize the semantic chunking strategy."""
self.python_chunker = PythonSemanticChunker()
self.markdown_chunker = MarkdownSemanticChunker()
self.scorer = ChunkRelevanceScorer()
def chunk_file(self, file_path: str, content: str) -> list[SemanticChunk]:
"""Chunk a file based on its type."""
path = Path(file_path)
if path.suffix == ".py":
return self.python_chunker.chunk(file_path, content)
elif path.suffix in (".md", ".markdown"):
return self.markdown_chunker.chunk(file_path, content)
else:
return [
SemanticChunk(
file_path=file_path,
chunk_type="file",
name=path.stem,
start_line=1,
end_line=len(content.splitlines()),
content=content,
relevance_score=0.5,
)
]
def select_chunks(
self,
chunks: list[SemanticChunk],
query: str | None = None,
context_budget: int = 4096,
) -> list[SemanticChunk]:
"""Select chunks based on relevance and budget constraints."""
scored_chunks = [
(chunk, self.scorer.score(chunk, query, context_budget))
for chunk in chunks
]
scored_chunks.sort(key=lambda x: x[1], reverse=True)
selected: list[SemanticChunk] = []
total_size = 0
for chunk, score in scored_chunks:
chunk_size = len(chunk.content)
if total_size + chunk_size <= context_budget:
chunk.relevance_score = score
selected.append(chunk)
total_size += chunk_size
return selected
def to_context_fragments(
self, chunks: list[SemanticChunk]
) -> list[ContextFragment]:
"""Convert semantic chunks to context fragments."""
fragments: list[ContextFragment] = []
for chunk in chunks:
provenance = FragmentProvenance(
resource_uri=chunk.file_path,
location=f"{chunk.start_line}-{chunk.end_line}",
strategy="semantic_chunking",
)
fragment = ContextFragment(
uko_node=f"file://{chunk.file_path}#{chunk.name}",
content=chunk.content,
detail_depth=0,
token_count=len(chunk.content.split()),
relevance_score=chunk.relevance_score,
provenance=provenance,
metadata={
"chunk_type": chunk.chunk_type,
"chunk_name": chunk.name,
"relevance_score": str(chunk.relevance_score),
},
)
fragments.append(fragment)
return fragments