mirror of
https://github.com/val1813/kwcode.git
synced 2026-09-05 23:50:44 +08:00
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>
184 lines
5.9 KiB
Python
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
|