mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 20:27:04 +01:00
Wraps the four install stages into named functions, one per lib/plagiarism_*.sh sibling (matching install_exercism.sh's convention), and extracts the two embedded Python heredocs into real, lint-clean check_plagiarism.py/sherlock.py files -- check_plagiarism.py needed a second split into plagiarism_similarity.py to also clear the 250-line cap. Every function reaches 100% shell coverage via a PATH-shim test harness (fake python3/pip/git/java/wine); the two Python CLIs were separately verified against a real venv with actual nltk/scikit-learn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
142 lines
4.8 KiB
Python
Executable File
142 lines
4.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Document reading and TF-IDF similarity scoring for check_plagiarism.py.
|
|
|
|
Split out of `check_plagiarism.py` to keep it under the 250-line cap: this
|
|
module holds the comparison engine (file reading, text preprocessing, and the
|
|
TF-IDF/cosine-similarity computations), `check_plagiarism.py` keeps the CLI
|
|
and reporting.
|
|
|
|
Its third-party imports (numpy, sklearn, nltk) are the plagiarism toolchain's
|
|
dependencies, not this repo's, so they are absent from `meta/requirements.txt`
|
|
on purpose. Importing them dynamically (rather than with a static `import
|
|
nltk`) keeps this module importable -- and lintable -- in an environment
|
|
that doesn't have them installed, matching `download_nltk_data.py`'s
|
|
extraction.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
#: Cosine similarity above this, for a single sentence pair, is worth reporting
|
|
#: as a suspicious passage in --detailed mode.
|
|
PASSAGE_SIMILARITY_THRESHOLD = 0.5
|
|
|
|
nltk = importlib.import_module("nltk")
|
|
stopwords = importlib.import_module("nltk.corpus").stopwords
|
|
nltk_tokenize = importlib.import_module("nltk.tokenize")
|
|
sent_tokenize = nltk_tokenize.sent_tokenize
|
|
word_tokenize = nltk_tokenize.word_tokenize
|
|
TfidfVectorizer = importlib.import_module(
|
|
"sklearn.feature_extraction.text"
|
|
).TfidfVectorizer
|
|
cosine_similarity = importlib.import_module(
|
|
"sklearn.metrics.pairwise"
|
|
).cosine_similarity
|
|
|
|
try:
|
|
stopwords.words("english")
|
|
except LookupError:
|
|
nltk.download("stopwords", quiet=True)
|
|
nltk.download("punkt", quiet=True)
|
|
|
|
|
|
def _out(line: str = "") -> None:
|
|
sys.stdout.write(line + "\n")
|
|
|
|
|
|
def _read_pdf(filepath: str) -> str:
|
|
try:
|
|
pypdf2 = importlib.import_module("PyPDF2")
|
|
except ImportError:
|
|
_out("Warning: PyPDF2 not installed, cannot read PDF files")
|
|
return ""
|
|
reader = pypdf2.PdfReader(filepath)
|
|
return " ".join(page.extract_text() or "" for page in reader.pages)
|
|
|
|
|
|
def _read_docx(filepath: str) -> str:
|
|
try:
|
|
docx = importlib.import_module("docx")
|
|
except ImportError:
|
|
_out("Warning: python-docx not installed, cannot read DOCX files")
|
|
return ""
|
|
doc = docx.Document(filepath)
|
|
return " ".join(para.text for para in doc.paragraphs)
|
|
|
|
|
|
def read_file(filepath: str) -> str:
|
|
"""Read text from a .pdf, .docx, or plain-text file."""
|
|
path = Path(filepath)
|
|
suffix = path.suffix.lower()
|
|
|
|
if suffix == ".pdf":
|
|
return _read_pdf(filepath)
|
|
if suffix == ".docx":
|
|
return _read_docx(filepath)
|
|
with path.open(encoding="utf-8", errors="ignore") as f:
|
|
return f.read()
|
|
|
|
|
|
def preprocess_text(text: str) -> str:
|
|
"""Lowercase, tokenize, and strip stopwords/non-alphanumerics."""
|
|
text = text.lower()
|
|
try:
|
|
stop_words = set(stopwords.words("english"))
|
|
words = word_tokenize(text)
|
|
words = [w for w in words if w.isalnum() and w not in stop_words]
|
|
return " ".join(words)
|
|
except LookupError:
|
|
return " ".join(text.split())
|
|
|
|
|
|
def compute_similarity_matrix(documents: list[str]) -> list[list[float]]:
|
|
"""Compute the TF-IDF cosine similarity matrix for a set of documents.
|
|
|
|
Returns a plain nested list, not the numpy array `cosine_similarity`
|
|
produces: numpy is a plagiarism-toolchain dependency the installer
|
|
pip-installs at runtime, deliberately absent from `meta/requirements.txt`,
|
|
so mypy cannot follow it into this repo's type-checked surface
|
|
(`no-any-unimported`). Converting at this boundary keeps every other
|
|
signature in the module concretely typed.
|
|
"""
|
|
vectorizer = TfidfVectorizer(ngram_range=(1, 3), min_df=1, max_df=0.95)
|
|
tfidf_matrix = vectorizer.fit_transform(documents)
|
|
matrix: list[list[float]] = cosine_similarity(tfidf_matrix).tolist()
|
|
return matrix
|
|
|
|
|
|
def find_similar_passages(
|
|
text1: str, text2: str, min_words: int = 5
|
|
) -> list[tuple[str, str, float]]:
|
|
"""Find similar sentence-level passages between two texts."""
|
|
sentences1 = [s for s in sent_tokenize(text1) if len(s.split()) >= min_words]
|
|
sentences2 = [s for s in sent_tokenize(text2) if len(s.split()) >= min_words]
|
|
|
|
if not sentences1 or not sentences2:
|
|
return []
|
|
|
|
all_sentences = sentences1 + sentences2
|
|
preprocessed = [preprocess_text(s) for s in all_sentences]
|
|
|
|
try:
|
|
vectorizer = TfidfVectorizer(ngram_range=(1, 2))
|
|
tfidf_matrix = vectorizer.fit_transform(preprocessed)
|
|
except ValueError:
|
|
return []
|
|
|
|
n1 = len(sentences1)
|
|
similarities = []
|
|
|
|
for i, s1 in enumerate(sentences1):
|
|
for j, s2 in enumerate(sentences2):
|
|
sim = cosine_similarity(
|
|
tfidf_matrix[i : i + 1], tfidf_matrix[n1 + j : n1 + j + 1]
|
|
)[0][0]
|
|
if sim > PASSAGE_SIMILARITY_THRESHOLD:
|
|
similarities.append((s1, s2, sim))
|
|
|
|
return sorted(similarities, key=lambda x: x[2], reverse=True)
|