Files
kwcode/kaiwu/knowledge/doc_reader.py
YonghaoZhao722 dad0148950 fix: normalize file paths in DocReader cache for cross-platform compatibility
Resolves Windows test failure where short path format (RUNNER~1) didn't match
long path format (runneradmin) in cache lookups. Also fixes macOS symlink
issues (/var vs /private/var).

Changes:
- Use path.resolve() for cache keys in DocReader._read_file
- Update test to use resolved paths for assertion

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:02:35 +08:00

184 lines
5.9 KiB
Python

"""
Project document reader.
Reads PDF/Word/MD/TXT files, BM25 matches relevant paragraphs.
P1-RED-4: Read failure degrades gracefully, never interrupts main flow.
P1-FLEX-2: Scanned PDFs (empty text) silently skipped.
"""
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
SUPPORTED_EXTENSIONS = {".pdf", ".docx", ".md", ".txt", ".rst"}
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", ".eggs", ".kaiwu"}
MAX_FILE_SIZE_MB = 10
# CJK Unicode ranges for tokenization
_CJK_RE = re.compile(
r'([\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff'
r'\U00020000-\U0002a6df\U0002a700-\U0002ebef])'
)
def _tokenize(text: str) -> list[str]:
"""
Simple tokenizer that handles both CJK and Latin text.
CJK characters are split into individual chars; Latin words stay intact.
"""
# Insert spaces around each CJK character
spaced = _CJK_RE.sub(r' \1 ', text.lower())
return [t for t in spaced.split() if len(t) >= 1]
class DocReader:
def __init__(self, project_root: str):
self.project_root = Path(project_root).resolve()
self._cache: dict[str, list[str]] = {}
def find_relevant(self, query: str, max_paragraphs: int = 5,
max_tokens: int = 800) -> str:
"""
Find paragraphs most relevant to query via BM25.
Returns concatenated text, capped at max_tokens (~4 chars/token).
"""
all_paragraphs: list[tuple[str, str]] = [] # (filename, text)
for doc_file in self._find_doc_files():
try:
paragraphs = self._read_file(doc_file)
for p in paragraphs:
if len(p.strip()) > 10:
all_paragraphs.append((doc_file.name, p.strip()))
except Exception as e:
logger.debug("[doc_reader] skipping %s: %s", doc_file.name, e)
continue # P1-RED-4
if not all_paragraphs:
return ""
# BM25 matching
try:
from rank_bm25 import BM25Plus
except ImportError:
logger.debug("[doc_reader] rank_bm25 not installed, skipping")
return ""
corpus = [_tokenize(p[1]) for p in all_paragraphs]
bm25 = BM25Plus(corpus)
query_tokens = _tokenize(query)
scores = bm25.get_scores(query_tokens)
top_indices = sorted(
range(len(scores)), key=lambda i: scores[i], reverse=True
)[:max_paragraphs]
relevant = [
all_paragraphs[i] for i in top_indices if scores[i] > 0
]
if not relevant:
return ""
# Assemble output within token budget
parts = []
total_chars = 0
max_chars = max_tokens * 4
for fname, paragraph in relevant:
snippet = f"[{fname}]\n{paragraph}"
if total_chars + len(snippet) > max_chars:
break
parts.append(snippet)
total_chars += len(snippet)
return "\n\n".join(parts)
def _find_doc_files(self) -> list[Path]:
result = []
try:
for path in self.project_root.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
if path.stat().st_size > MAX_FILE_SIZE_MB * 1024 * 1024:
continue
result.append(path)
except Exception as e:
logger.debug("[doc_reader] scan error: %s", e)
return result
def _read_file(self, path: Path) -> list[str]:
key = str(path.resolve())
if key in self._cache:
return self._cache[key]
suffix = path.suffix.lower()
paragraphs: list[str] = []
if suffix == ".pdf":
paragraphs = self._read_pdf(path)
elif suffix == ".docx":
paragraphs = self._read_docx(path)
elif suffix in (".md", ".txt", ".rst"):
paragraphs = self._read_text(path)
self._cache[key] = paragraphs
return paragraphs
def _read_pdf(self, path: Path) -> list[str]:
try:
import pdfplumber
except ImportError:
logger.debug("[doc_reader] pdfplumber not installed")
return []
paragraphs = []
try:
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
for para in text.split("\n\n"):
if len(para.strip()) > 30:
paragraphs.append(para.strip())
except Exception as e:
logger.debug("[doc_reader] PDF read error %s: %s", path.name, e)
if not paragraphs:
logger.debug("[doc_reader] %s: empty text (scanned PDF?), skipping", path.name)
return paragraphs
def _read_docx(self, path: Path) -> list[str]:
try:
from docx import Document
except ImportError:
logger.debug("[doc_reader] python-docx not installed")
return []
paragraphs = []
try:
doc = Document(path)
for para in doc.paragraphs:
if len(para.text.strip()) > 30:
paragraphs.append(para.text.strip())
except Exception as e:
logger.debug("[doc_reader] DOCX read error %s: %s", path.name, e)
return paragraphs
def _read_text(self, path: Path) -> list[str]:
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return []
paragraphs = []
for para in text.split("\n\n"):
if len(para.strip()) > 30:
paragraphs.append(para.strip())
return paragraphs