This commit is contained in:
Forrest 2026-07-27 23:29:05 -04:00
commit 971e49db5c
246 changed files with 93082 additions and 0 deletions

View file

@ -0,0 +1,534 @@
#!/usr/bin/env python3
"""
D&D 5e SRD Context Expansion Tool
This tool expands the context around search results from search_with_positions.py,
providing larger, structured views of the source material. Designed for LLM consumption.
Usage:
# Expand specific result from a search
python expand_context.py "fireball" --result 3 --mode section --all
# Expand multiple results
python expand_context.py "fireball" --results 1,3,5 --mode paragraph --all
# Direct expansion from file position
python expand_context.py --file "DND5eSRD_121-137.md" --position 1234 --mode section
# Get full document structure with position
python expand_context.py --file "DND5eSRD_121-137.md" --position 1234 --mode document
"""
import argparse
import json
import os
import re
# Import from search tool in the same directory
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
script_dir = Path(__file__).parent
sys.path.insert(0, str(script_dir))
from search_with_positions import (
get_all_reference_files,
get_files_by_page_range,
get_references_dir,
search_files,
)
@dataclass
class HeadingNode:
"""Represents a heading in the document hierarchy."""
level: int
text: str
start_pos: int
end_pos: int
content: str = ""
children: List["HeadingNode"] = None
def __post_init__(self):
if self.children is None:
self.children = []
@dataclass
class ExpandedContext:
"""Represents expanded context around a position."""
file_path: str
original_match: str
match_start: int
match_end: int
expanded_text: str
expansion_start: int
expansion_end: int
mode: str
heading_path: List[str]
metadata: Dict = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
class DocumentParser:
"""Parses markdown documents with structure awareness."""
def __init__(self, file_path: Path):
self.file_path = file_path
with open(file_path, "r", encoding="utf-8") as f:
self.content = f.read()
self.headings = self._parse_headings()
self.paragraphs = self._parse_paragraphs()
def _parse_headings(self) -> List[Dict]:
"""Parse all headings with their positions."""
headings: List[Dict] = []
pattern = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
for match in pattern.finditer(self.content):
level = len(match.group(1))
text = match.group(2).strip()
start_pos = match.start()
headings.append(
{
"level": level,
"text": text,
"start_pos": start_pos,
"end_pos": match.end(),
}
)
return headings
def _parse_paragraphs(self) -> List[Tuple[int, int]]:
"""Parse paragraph boundaries (text blocks separated by blank lines)."""
paragraphs: List[Tuple[int, int]] = []
in_paragraph = False
para_start = 0
lines = self.content.split("\n")
line_pos = 0
for line in lines:
line_start = line_pos
line_end = line_pos + len(line)
line_pos = line_end + 1 # +1 for newline
if line.strip():
if not in_paragraph:
para_start = line_start
in_paragraph = True
else:
if in_paragraph:
paragraphs.append((para_start, line_start))
in_paragraph = False
if in_paragraph:
paragraphs.append((para_start, len(self.content)))
return paragraphs
def get_heading_path(self, position: int) -> List[str]:
"""Get the breadcrumb trail of headings for a position."""
path: List[str] = []
current_levels: Dict[int, str] = {}
for heading in self.headings:
if heading["start_pos"] > position:
break
level = heading["level"]
keys_to_remove = [k for k in current_levels.keys() if k >= level]
for k in keys_to_remove:
del current_levels[k]
current_levels[level] = heading["text"]
for level in sorted(current_levels.keys()):
path.append(current_levels[level])
return path
def get_section_bounds(
self, position: int, include_subsections: bool = True
) -> Tuple[int, int]:
"""Get the bounds of the section containing the position."""
containing_heading = None
containing_level = None
for i, heading in enumerate(self.headings):
if heading["start_pos"] <= position:
containing_heading = i
containing_level = heading["level"]
else:
break
if containing_heading is None:
if self.headings:
return (0, self.headings[0]["start_pos"])
return (0, len(self.content))
section_start = self.headings[containing_heading]["start_pos"]
section_end = len(self.content)
for i in range(containing_heading + 1, len(self.headings)):
next_heading = self.headings[i]
if include_subsections:
if next_heading["level"] <= containing_level:
section_end = next_heading["start_pos"]
break
else:
section_end = next_heading["start_pos"]
break
return (section_start, section_end)
def get_paragraph_bounds(self, position: int) -> Tuple[int, int]:
"""Get the bounds of the paragraph containing the position."""
for para_start, para_end in self.paragraphs:
if para_start <= position < para_end:
return (para_start, para_end)
return (max(0, position - 500), min(len(self.content), position + 500))
def get_document_structure(self, focus_position: Optional[int] = None) -> Dict:
"""Get the full document structure with optional focus on a position."""
structure: Dict = {
"file": os.path.basename(self.file_path),
"total_chars": len(self.content),
"headings": [],
}
for heading in self.headings:
heading_info = {
"level": heading["level"],
"text": heading["text"],
"position": heading["start_pos"],
"is_focus": False,
}
if focus_position is not None:
start = heading["start_pos"]
end = len(self.content)
for next_h in self.headings:
if (
next_h["start_pos"] > start
and next_h["level"] <= heading["level"]
):
end = next_h["start_pos"]
break
if start <= focus_position < end:
heading_info["is_focus"] = True
structure["headings"].append(heading_info)
return structure
def expand_context(
file_path: Path,
position: int,
match_text: str,
mode: str = "paragraph",
match_end: Optional[int] = None,
) -> ExpandedContext:
"""Expand context around a position with various modes."""
if match_end is None:
match_end = position + len(match_text)
parser = DocumentParser(file_path)
heading_path = parser.get_heading_path(position)
if mode == "char":
char_context = 1000
start = max(0, position - char_context)
end = min(len(parser.content), match_end + char_context)
expanded_text = parser.content[start:end]
elif mode == "paragraph":
start, end = parser.get_paragraph_bounds(position)
expanded_text = parser.content[start:end]
elif mode == "section":
start, end = parser.get_section_bounds(position, include_subsections=True)
expanded_text = parser.content[start:end]
elif mode == "section-only":
start, end = parser.get_section_bounds(position, include_subsections=False)
expanded_text = parser.content[start:end]
elif mode == "document":
start = 0
end = len(parser.content)
expanded_text = parser.content
else:
raise ValueError(f"Unknown mode: {mode}")
return ExpandedContext(
file_path=str(file_path),
original_match=match_text,
match_start=position,
match_end=match_end,
expanded_text=expanded_text,
expansion_start=start,
expansion_end=end,
mode=mode,
heading_path=heading_path,
metadata={
"file_size": len(parser.content),
"expansion_size": len(expanded_text),
"num_headings_in_path": len(heading_path),
},
)
def format_expanded_context(
context: ExpandedContext, format_type: str = "human"
) -> str:
"""Format expanded context for output."""
if format_type == "json":
return json.dumps(asdict(context), indent=2)
elif format_type == "human":
output: List[str] = []
output.append("=" * 80)
output.append(f"File: {os.path.basename(context.file_path)}")
output.append(f"Mode: {context.mode}")
output.append(f"Original Match: '{context.original_match}'")
output.append(f"Match Position: {context.match_start}-{context.match_end}")
output.append(
f"Expanded Range: {context.expansion_start}-{context.expansion_end}"
)
output.append(f"Expansion Size: {context.metadata['expansion_size']} chars")
if context.heading_path:
output.append("\nHeading Path:")
for i, heading in enumerate(context.heading_path, 1):
output.append(f" {' ' * (i - 1)}→ {heading}")
output.append("\n" + "-" * 80)
output.append("EXPANDED CONTENT:")
output.append("-" * 80)
output.append(context.expanded_text)
output.append("=" * 80)
return "\n".join(output)
elif format_type == "llm":
output: List[str] = []
output.append("### EXPANDED CONTEXT")
output.append(f"**Source**: {os.path.basename(context.file_path)}")
output.append(
f"**Character Range**: {context.expansion_start}-{context.expansion_end}"
)
output.append(
f"**Original Match** at position {context.match_start}: `{context.original_match}`"
)
if context.heading_path:
breadcrumb = " → ".join(context.heading_path)
output.append(f"**Location**: {breadcrumb}")
output.append("\n---\n")
output.append(context.expanded_text)
output.append("\n---\n")
return "\n".join(output)
def main():
parser = argparse.ArgumentParser(
description=("Expand context around search results from D&D 5e SRD references"),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"""
Examples:
# Expand the 3rd search result by section
python expand_context.py "fireball" --result 3 --mode section --all
# Expand multiple results by paragraph
python expand_context.py "wizard" --results 1,3,5 --mode paragraph --all
# Direct expansion from file position
python expand_context.py --file "DND5eSRD_121-137.md" --position 1234 --mode section
# Get document structure
python expand_context.py --file "DND5eSRD_121-137.md" --position 1234 --mode document
# Output as JSON for machine processing
python expand_context.py "fireball" --result 1 --all --format json
Expansion Modes:
char - Expand by ±1000 characters (simple)
paragraph - Expand to paragraph boundaries (blank line separated)
section - Expand to full section including subsections
section-only - Expand to section excluding subsections
document - Show entire document (use with caution)
Output Formats:
human - Human-readable formatted output
llm - Optimized for LLM consumption (default)
json - Machine-readable JSON
"""
),
)
# Search parameters (for finding results to expand)
parser.add_argument("search_term", nargs="?", help="Term to search for")
parser.add_argument("--result", type=int, help="Result number to expand (1-based)")
parser.add_argument(
"--results", help="Comma-separated result numbers to expand (e.g., '1,3,5')"
)
parser.add_argument(
"--all-search",
action="store_true",
dest="search_all",
help="Search all reference files",
)
parser.add_argument("--pages", help="Search files in page range (e.g., 001-120)")
parser.add_argument("--files", nargs="+", help="Specific files to search")
# Direct expansion parameters
parser.add_argument("--file", help="Direct file path for expansion")
parser.add_argument(
"--position", type=int, help="Character position for direct expansion"
)
# Expansion options
parser.add_argument(
"--mode",
choices=["char", "paragraph", "section", "section-only", "document"],
default="paragraph",
help="Expansion mode (default: paragraph)",
)
parser.add_argument(
"--format",
choices=["human", "llm", "json"],
default="llm",
help="Output format (default: llm)",
)
# Search options
parser.add_argument(
"--case-sensitive", action="store_true", help="Case-sensitive search"
)
parser.add_argument(
"--max-search-results",
type=int,
default=50,
help="Maximum search results to find (default: 50)",
)
args = parser.parse_args()
# Direct expansion mode
if args.file and args.position is not None:
file_path = Path(args.file)
if not file_path.exists():
refs_dir = get_references_dir()
file_path = refs_dir / args.file
if not file_path.exists():
print(f"Error: File not found: {args.file}")
return
context = expand_context(
file_path=file_path,
position=args.position,
match_text="[direct position]",
mode=args.mode,
)
print(format_expanded_context(context, args.format))
return
# Search-based expansion mode
if not args.search_term:
print("Error: Provide either search_term + result, or --file + --position")
parser.print_help()
return
if not args.result and not args.results:
print("Error: Specify which result(s) to expand with --result or --results")
return
# Determine files to search
files_to_search: List[Path] = []
refs_dir = get_references_dir()
if args.search_all:
files_to_search = get_all_reference_files()
elif args.pages:
files_to_search = get_files_by_page_range(args.pages)
elif args.files:
for filename in args.files:
file_path = refs_dir / filename
if file_path.exists():
files_to_search.append(file_path)
else:
print(f"Warning: File not found: {filename}")
else:
files_to_search = get_all_reference_files()
if not files_to_search:
print("Error: No files to search")
return
# Perform search
print(f"Searching for '{args.search_term}'...", file=sys.stderr)
search_results = search_files(
files_to_search,
args.search_term,
case_sensitive=args.case_sensitive,
max_results=args.max_search_results,
context_chars=50,
)
if not search_results:
print(f"No results found for '{args.search_term}'")
return
print(f"Found {len(search_results)} results\n", file=sys.stderr)
# Determine which results to expand
results_to_expand: List[int] = []
if args.results:
result_nums = [int(x.strip()) for x in args.results.split(",")]
results_to_expand = result_nums
elif args.result:
results_to_expand = [args.result]
# Expand specified results
for result_num in results_to_expand:
if result_num < 1 or result_num > len(search_results):
print(
f"Warning: Result {result_num} out of range (1-{len(search_results)})"
)
continue
search_result = search_results[result_num - 1]
print(f"\n{'=' * 80}", file=sys.stderr)
print(f"EXPANDING RESULT {result_num}/{len(search_results)}", file=sys.stderr)
print(f"{'=' * 80}\n", file=sys.stderr)
context = expand_context(
file_path=Path(search_result.file_path),
position=search_result.start_pos,
match_text=search_result.match_text,
mode=args.mode,
match_end=search_result.end_pos,
)
print(format_expanded_context(context, args.format))
if len(results_to_expand) > 1 and result_num != results_to_expand[-1]:
print("\n" + "=" * 80 + "\n")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
D&D 5e SRD Search Tool with Character Positions
This tool searches through the D&D 5e SRD reference files and returns
results with exact character positions for precise source citation.
Usage:
python search_with_positions.py "search term" [--files file1.md file2.md]
python search_with_positions.py "search term" --all
python search_with_positions.py "search term" --pages 001-120
"""
import argparse
import os
import re
from pathlib import Path
from typing import List
class SearchResult:
"""Represents a single search result with character positions."""
def __init__(
self,
file_path: str,
match_text: str,
start_pos: int,
end_pos: int,
context_before: str = "",
context_after: str = "",
):
self.file_path = file_path
self.match_text = match_text
self.start_pos = start_pos
self.end_pos = end_pos
self.context_before = context_before
self.context_after = context_after
def __str__(self):
filename = os.path.basename(self.file_path)
return (
f"File: {filename}\n"
f"Position: chars {self.start_pos}-{self.end_pos}\n"
f"Match: {self.match_text}\n"
f"Context: ...{self.context_before}[{self.match_text}]{self.context_after}...\n"
)
def get_references_dir() -> Path:
"""Resolve the path to the references directory.
Returns the ./references directory alongside this script's parent directory.
"""
script_dir = Path(__file__).parent
skill_dir = script_dir.parent
local_refs = skill_dir / "references"
return local_refs
def get_all_reference_files() -> List[Path]:
"""Get all markdown files in the references directory."""
refs_dir = get_references_dir()
return sorted(refs_dir.glob("*.md"))
def get_files_by_page_range(page_range: str) -> List[Path]:
"""
Get files that match a page range pattern (e.g., '090-223').
This handles both exact matches and range spans.
"""
refs_dir = get_references_dir()
# Try exact pattern match first
pattern = f"*{page_range}*.md"
exact_matches = sorted(refs_dir.glob(pattern))
if exact_matches:
return exact_matches
# Parse the range to find all files that fall within it
try:
start_page, end_page = map(int, page_range.split("-"))
except ValueError:
# If not a valid range, try as a simple substring pattern
return sorted(refs_dir.glob(f"*{page_range}*.md"))
# Find all files whose page ranges overlap with the requested range
matching_files = []
for file_path in refs_dir.glob("*.md"):
# Extract page range from filename suffix like ..._123-456.md
match = re.search(r"(\d{3})-(\d{3})\.md$", file_path.name)
if match:
file_start = int(match.group(1))
file_end = int(match.group(2))
if not (file_end < start_page or file_start > end_page):
matching_files.append(file_path)
return sorted(matching_files)
def search_in_file(
file_path: Path,
search_term: str,
case_sensitive: bool = False,
context_chars: int = 50,
) -> List[SearchResult]:
"""
Search for a term in a file and return results with character positions.
"""
results: List[SearchResult] = []
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
flags = 0 if case_sensitive else re.IGNORECASE
pattern = re.compile(re.escape(search_term), flags)
for match in pattern.finditer(content):
start_pos = match.start()
end_pos = match.end()
match_text = match.group()
context_start = max(0, start_pos - context_chars)
context_end = min(len(content), end_pos + context_chars)
context_before = content[context_start:start_pos]
context_after = content[end_pos:context_end]
results.append(
SearchResult(
file_path=str(file_path),
match_text=match_text,
start_pos=start_pos,
end_pos=end_pos,
context_before=context_before,
context_after=context_after,
)
)
except Exception as e:
print(f"Error reading {file_path}: {e}")
return results
def search_files(
files: List[Path],
search_term: str,
case_sensitive: bool = False,
max_results: int = 20,
context_chars: int = 50,
) -> List[SearchResult]:
"""Search for a term across multiple files."""
all_results: List[SearchResult] = []
for file_path in files:
results = search_in_file(file_path, search_term, case_sensitive, context_chars)
all_results.extend(results)
if len(all_results) >= max_results:
break
return all_results[:max_results]
def format_results_for_citation(results: List[SearchResult]) -> str:
"""Format results in a citation-friendly way."""
if not results:
return "No results found."
output = f"\nFound {len(results)} result(s):\n"
output += "=" * 80 + "\n\n"
for i, result in enumerate(results, 1):
output += f"Result {i}:\n"
output += f" File: {os.path.basename(result.file_path)}\n"
output += f" Character Range: {result.start_pos}-{result.end_pos}\n"
output += f" Citation: [{os.path.basename(result.file_path)}, chars {result.start_pos}-{result.end_pos}]\n"
output += "\n Context:\n"
output += f" ...{result.context_before.strip()}\n"
output += f" >> {result.match_text} <<\n"
output += f" {result.context_after.strip()}...\n"
output += "\n" + "---" + "\n\n"
return output
def main():
parser = argparse.ArgumentParser(
description=(
"Search D&D 5e SRD references with character positions for citation"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"""
Examples:
# Search all files
python search_with_positions.py "fireball" --all
# Search specific page range
python search_with_positions.py "wizard" --pages 121-200
# Search specific files
python search_with_positions.py "grapple" --files "DND5eSRD_087-103.md"
# Case-sensitive search with more context
python search_with_positions.py "Attack" --all --case-sensitive --context 100
"""
),
)
parser.add_argument("search_term", help="Term to search for")
parser.add_argument("--files", nargs="+", help="Specific files to search")
parser.add_argument("--all", action="store_true", help="Search all reference files")
parser.add_argument("--pages", help="Search files in page range (e.g., 001-120)")
parser.add_argument(
"--case-sensitive", action="store_true", help="Case-sensitive search"
)
parser.add_argument(
"--max-results", type=int, default=20, help="Maximum results (default: 20)"
)
parser.add_argument(
"--context", type=int, default=100, help="Characters of context (default: 100)"
)
args = parser.parse_args()
# Determine which files to search
refs_dir = get_references_dir()
files_to_search: List[Path] = []
if args.all:
files_to_search = get_all_reference_files()
elif args.pages:
files_to_search = get_files_by_page_range(args.pages)
elif args.files:
# Convert provided filenames to full paths
for filename in args.files:
file_path = refs_dir / filename
if file_path.exists():
files_to_search.append(file_path)
else:
print(f"Warning: File not found: {filename}")
else:
files_to_search = get_all_reference_files()
if not files_to_search:
print("Error: No files to search. Use --all, --pages, or --files")
return
print(f"Searching {len(files_to_search)} file(s) for '{args.search_term}'...")
# Perform search
results = search_files(
files_to_search,
args.search_term,
case_sensitive=args.case_sensitive,
max_results=args.max_results,
context_chars=args.context,
)
# Display results
print(format_results_for_citation(results))
if __name__ == "__main__":
main()