Spaces:
Running
Running
Commit ·
fa364c7
1
Parent(s): 057a2a1
deploy: similarity feature + keyless providers (squashed for HF Spaces)
Browse files- agents/__init__.py +1 -0
- agents/similarity_agent.py +100 -0
- backend/main.py +2 -0
- backend/routers/jobs.py +4 -0
- backend/routers/reports.py +28 -0
- backend/routers/similarity.py +164 -0
- backend/schemas/similarity.py +34 -0
- backend/tests/conftest.py +4 -0
- backend/tests/test_jobs.py +8 -0
- backend/tests/test_similarity.py +146 -0
- configs/pipeline_config.yaml +4 -0
- opencode-prompt.md +357 -0
- orchestrators/custom_orchestrator.py +20 -0
- plan.md +918 -0
- plan_done.md +143 -0
- services/academic_search.py +217 -0
- services/paper_recommender.py +27 -213
- similarity/__init__.py +0 -0
- similarity/aggregate.py +98 -0
- similarity/config.py +57 -0
- similarity/corpus/__init__.py +0 -0
- similarity/corpus/arxiv.py +120 -0
- similarity/corpus/base.py +43 -0
- similarity/corpus/core_api.py +108 -0
- similarity/corpus/crossref.py +84 -0
- similarity/corpus/internal.py +112 -0
- similarity/corpus/openalex.py +85 -0
- similarity/exclusions.py +129 -0
- similarity/fingerprint.py +69 -0
- similarity/index_writer.py +98 -0
- similarity/matcher.py +136 -0
- similarity/normalize.py +51 -0
- similarity/pipeline.py +303 -0
- similarity/schema.py +82 -0
- similarity/selector.py +109 -0
- similarity/semantic.py +54 -0
- tests/similarity/conftest.py +115 -0
- tests/similarity/test_aggregate.py +109 -0
- tests/similarity/test_corpus_providers.py +329 -0
- tests/similarity/test_exclusions.py +77 -0
- tests/similarity/test_fingerprint.py +89 -0
- tests/similarity/test_index_writer.py +103 -0
- tests/similarity/test_matcher.py +87 -0
- tests/similarity/test_normalize.py +52 -0
- tests/similarity/test_pipeline.py +205 -0
- tests/similarity/test_privacy.py +170 -0
- tests/similarity/test_selector.py +100 -0
- tests/similarity/test_self_match_guard.py +94 -0
- tests/similarity/test_semantic.py +84 -0
- tests/test_agents.py +14 -0
agents/__init__.py
CHANGED
|
@@ -11,3 +11,4 @@ from agents.claim_verifier_agent import ClaimVerifierAgent
|
|
| 11 |
from agents.counter_factuality_agent import CounterFactualityAgent
|
| 12 |
from agents.scoring_agent import ScoringAgent
|
| 13 |
from agents.report_agent import ReportAgent
|
|
|
|
|
|
| 11 |
from agents.counter_factuality_agent import CounterFactualityAgent
|
| 12 |
from agents.scoring_agent import ScoringAgent
|
| 13 |
from agents.report_agent import ReportAgent
|
| 14 |
+
from agents.similarity_agent import SimilarityAgent
|
agents/similarity_agent.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Similarity Agent (Wave 3 — parallel with claim/gap).
|
| 2 |
+
|
| 3 |
+
Reads the paper text from Neo4j, runs the corpus-similarity pipeline (plan.md
|
| 4 |
+
§8.1), and persists the `SimilarityReport` so the API can serve it verbatim.
|
| 5 |
+
`critical = False`: a CORE outage or a timeout must degrade the report to
|
| 6 |
+
`status="partial"`, never fail the whole job.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any, Dict
|
| 12 |
+
|
| 13 |
+
from agents.base_agent import BaseAgent, AgentContext
|
| 14 |
+
from similarity.config import settings
|
| 15 |
+
from similarity.corpus.arxiv import ArxivProvider
|
| 16 |
+
from similarity.corpus.core_api import CoreApiProvider
|
| 17 |
+
from similarity.corpus.crossref import CrossrefProvider
|
| 18 |
+
from similarity.corpus.internal import InternalCorpusProvider
|
| 19 |
+
from similarity.corpus.openalex import OpenAlexProvider
|
| 20 |
+
from similarity.pipeline import SimilarityPipeline
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_default_providers(mongo=None, *, doc_id: str, job_id: str | None = None):
|
| 24 |
+
"""Instantiate the configured providers, respecting the SIM_ENABLE_* toggles."""
|
| 25 |
+
providers = []
|
| 26 |
+
if settings.enable_core:
|
| 27 |
+
providers.append(CoreApiProvider())
|
| 28 |
+
if settings.enable_arxiv:
|
| 29 |
+
providers.append(ArxivProvider())
|
| 30 |
+
if settings.enable_openalex:
|
| 31 |
+
providers.append(OpenAlexProvider())
|
| 32 |
+
if settings.enable_crossref:
|
| 33 |
+
providers.append(CrossrefProvider())
|
| 34 |
+
if settings.enable_internal and mongo is not None:
|
| 35 |
+
providers.append(
|
| 36 |
+
InternalCorpusProvider(mongo, doc_id=doc_id, job_id=job_id)
|
| 37 |
+
)
|
| 38 |
+
return providers
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class SimilarityAgent(BaseAgent):
|
| 42 |
+
name = "similarity"
|
| 43 |
+
wave = 3
|
| 44 |
+
critical = False
|
| 45 |
+
|
| 46 |
+
async def execute(self, ctx: AgentContext) -> Dict[str, Any]:
|
| 47 |
+
rows = ctx.neo4j.run(
|
| 48 |
+
"MATCH (d:Document {doc_id: $doc_id})-[:HAS_SECTION]->(:Section)-[:HAS_PARAGRAPH]->(p:Paragraph) "
|
| 49 |
+
"RETURN p.text AS text ORDER BY p.position",
|
| 50 |
+
doc_id=ctx.doc_id,
|
| 51 |
+
)
|
| 52 |
+
if not rows:
|
| 53 |
+
rows = ctx.neo4j.run(
|
| 54 |
+
"MATCH (d:Document {doc_id: $doc_id})-[:HAS_SECTION]->(s:Section) "
|
| 55 |
+
"RETURN s.text AS text ORDER BY s.position",
|
| 56 |
+
doc_id=ctx.doc_id,
|
| 57 |
+
)
|
| 58 |
+
text = " ".join(r["text"] for r in rows if r.get("text"))
|
| 59 |
+
if len(text.split()) < settings.min_document_words:
|
| 60 |
+
self.logger.info(f"[{ctx.job_id}] Text too short for similarity report")
|
| 61 |
+
return {"status": "unavailable", "overall_percent": 0}
|
| 62 |
+
|
| 63 |
+
providers = build_default_providers(ctx.mongo, doc_id=ctx.doc_id, job_id=ctx.job_id)
|
| 64 |
+
pipeline = SimilarityPipeline(
|
| 65 |
+
providers=providers,
|
| 66 |
+
doc_title=ctx.extra.get("title", "") if ctx.extra else "",
|
| 67 |
+
embedder=ctx.embedder if settings.enable_paraphrase else None,
|
| 68 |
+
)
|
| 69 |
+
report = await pipeline.run(text, doc_id=ctx.doc_id)
|
| 70 |
+
payload = report.model_dump(mode="json")
|
| 71 |
+
|
| 72 |
+
# Persist summary nodes so the report survives a restart (plan.md §8.1).
|
| 73 |
+
try:
|
| 74 |
+
for src in report.sources[:20]:
|
| 75 |
+
ctx.neo4j.run_write(
|
| 76 |
+
"""
|
| 77 |
+
MATCH (d:Document {doc_id: $doc_id})
|
| 78 |
+
MERGE (sim:SimilaritySource {source_index: $idx, doc_id: $doc_id})
|
| 79 |
+
SET sim.title = $title, sim.url = $url, sim.bucket = $bucket,
|
| 80 |
+
sim.matched_words = $words, sim.percent = $percent,
|
| 81 |
+
sim.provider = $provider
|
| 82 |
+
MERGE (d)-[:HAS_SIMILARITY_SOURCE]->(sim)
|
| 83 |
+
""",
|
| 84 |
+
doc_id=ctx.doc_id,
|
| 85 |
+
idx=src.source_index,
|
| 86 |
+
title=src.title,
|
| 87 |
+
url=src.url,
|
| 88 |
+
bucket=src.bucket.value,
|
| 89 |
+
words=src.matched_words,
|
| 90 |
+
percent=src.percent,
|
| 91 |
+
provider=src.provider,
|
| 92 |
+
)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
self.logger.warning(f"[{ctx.job_id}] Could not persist similarity nodes: {e}")
|
| 95 |
+
|
| 96 |
+
self.logger.info(
|
| 97 |
+
f"[{ctx.job_id}] similarity | status={report.status} "
|
| 98 |
+
f"overall={report.overall_percent}%"
|
| 99 |
+
)
|
| 100 |
+
return payload
|
backend/main.py
CHANGED
|
@@ -29,6 +29,7 @@ from backend.routers import (
|
|
| 29 |
health,
|
| 30 |
jobs,
|
| 31 |
reports,
|
|
|
|
| 32 |
text_detection,
|
| 33 |
websocket,
|
| 34 |
)
|
|
@@ -125,6 +126,7 @@ def create_app() -> FastAPI:
|
|
| 125 |
app.include_router(groq.router)
|
| 126 |
app.include_router(jobs.router)
|
| 127 |
app.include_router(reports.router)
|
|
|
|
| 128 |
app.include_router(text_detection.router)
|
| 129 |
app.include_router(websocket.router)
|
| 130 |
|
|
|
|
| 29 |
health,
|
| 30 |
jobs,
|
| 31 |
reports,
|
| 32 |
+
similarity,
|
| 33 |
text_detection,
|
| 34 |
websocket,
|
| 35 |
)
|
|
|
|
| 126 |
app.include_router(groq.router)
|
| 127 |
app.include_router(jobs.router)
|
| 128 |
app.include_router(reports.router)
|
| 129 |
+
app.include_router(similarity.router)
|
| 130 |
app.include_router(text_detection.router)
|
| 131 |
app.include_router(websocket.router)
|
| 132 |
|
backend/routers/jobs.py
CHANGED
|
@@ -121,4 +121,8 @@ async def delete_job(
|
|
| 121 |
|
| 122 |
db = mongo._get_db()
|
| 123 |
await db.jobs.delete_one({"job_id": job_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
return {"deleted": job_id}
|
|
|
|
| 121 |
|
| 122 |
db = mongo._get_db()
|
| 123 |
await db.jobs.delete_one({"job_id": job_id})
|
| 124 |
+
|
| 125 |
+
# plan.md 16 risk 6: a deleted job must take its corpus rows with it.
|
| 126 |
+
from similarity.index_writer import purge_corpus_for_job
|
| 127 |
+
await purge_corpus_for_job(mongo, job_id=job_id, doc_id=job.get("doc_id"))
|
| 128 |
return {"deleted": job_id}
|
backend/routers/reports.py
CHANGED
|
@@ -16,6 +16,7 @@ from backend.schemas.reports import (
|
|
| 16 |
GraphSubgraphResponse,
|
| 17 |
GraphNodeResponse,
|
| 18 |
)
|
|
|
|
| 19 |
from services.mongo_service import MongoService
|
| 20 |
from services.neo4j_service import Neo4jService
|
| 21 |
from services.email_service import EmailService
|
|
@@ -247,6 +248,33 @@ async def get_result_json(
|
|
| 247 |
)
|
| 248 |
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
@router.get(
|
| 251 |
"/{job_id}/graph",
|
| 252 |
response_model=GraphSubgraphResponse,
|
|
|
|
| 16 |
GraphSubgraphResponse,
|
| 17 |
GraphNodeResponse,
|
| 18 |
)
|
| 19 |
+
from similarity.schema import SimilarityReport
|
| 20 |
from services.mongo_service import MongoService
|
| 21 |
from services.neo4j_service import Neo4jService
|
| 22 |
from services.email_service import EmailService
|
|
|
|
| 248 |
)
|
| 249 |
|
| 250 |
|
| 251 |
+
@router.get(
|
| 252 |
+
"/{job_id}/similarity",
|
| 253 |
+
response_model=SimilarityReport,
|
| 254 |
+
summary="Get the corpus-similarity report for a job",
|
| 255 |
+
)
|
| 256 |
+
async def get_similarity_report(
|
| 257 |
+
job_id: str,
|
| 258 |
+
mongo: MongoService = Depends(get_mongo),
|
| 259 |
+
):
|
| 260 |
+
"""Serve the stored `SimilarityReport` (written by the orchestrator)."""
|
| 261 |
+
job = await mongo.get_job(job_id)
|
| 262 |
+
if not job:
|
| 263 |
+
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found.")
|
| 264 |
+
|
| 265 |
+
result = await mongo.get_result(job_id)
|
| 266 |
+
report = (result or {}).get("similarity")
|
| 267 |
+
if not report:
|
| 268 |
+
raise HTTPException(
|
| 269 |
+
status_code=202,
|
| 270 |
+
detail=(
|
| 271 |
+
f"Similarity report not ready yet. Job status: "
|
| 272 |
+
f"{job.get('status', 'unknown')}"
|
| 273 |
+
),
|
| 274 |
+
)
|
| 275 |
+
return report
|
| 276 |
+
|
| 277 |
+
|
| 278 |
@router.get(
|
| 279 |
"/{job_id}/graph",
|
| 280 |
response_model=GraphSubgraphResponse,
|
backend/routers/similarity.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus-similarity endpoints (plan.md §9).
|
| 2 |
+
|
| 3 |
+
GET /similarity/status -> 200 SimilarityStatus
|
| 4 |
+
POST /similarity/{job_id}/recheck -> 202 {job_id, status} | 409 | 404
|
| 5 |
+
|
| 6 |
+
The report itself lives at `GET /reports/{job_id}/similarity` in reports.py
|
| 7 |
+
beside /json, /pdf and /graph.
|
| 8 |
+
|
| 9 |
+
`recheck` re-runs the pipeline against the current corpus using the text the
|
| 10 |
+
parser already stored in Neo4j - no PDF re-parse, no full re-analysis. It runs
|
| 11 |
+
as a BackgroundTask (same as POST /analyze) and is guarded by an in-process
|
| 12 |
+
set keyed by job_id: four clicks on the button must not launch four concurrent
|
| 13 |
+
24-query provider storms. The guard is process-local, which matches how the
|
| 14 |
+
whole app is deployed today (single uvicorn worker).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import Callable
|
| 19 |
+
|
| 20 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 21 |
+
|
| 22 |
+
from backend.dependencies import get_embedder, get_mongo, get_neo4j
|
| 23 |
+
from backend.schemas.similarity import RecheckAccepted, SimilarityStatus
|
| 24 |
+
from services.mongo_service import MongoService
|
| 25 |
+
from services.neo4j_service import Neo4jService
|
| 26 |
+
from services.vector_store_service import VectorStoreService
|
| 27 |
+
from similarity.config import settings as sim_settings
|
| 28 |
+
from utils.logger import get_logger
|
| 29 |
+
|
| 30 |
+
logger = get_logger("backend.routers.similarity")
|
| 31 |
+
|
| 32 |
+
router = APIRouter(prefix="/api/similarity", tags=["Similarity"])
|
| 33 |
+
|
| 34 |
+
# In-process guard: job_ids whose recheck is currently running.
|
| 35 |
+
_recheck_in_flight: set[str] = set()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_recheck_in_flight() -> set[str]:
|
| 39 |
+
"""Expose the in-flight guard (also used by tests to seed a 409)."""
|
| 40 |
+
return _recheck_in_flight
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _enabled_providers() -> list[str]:
|
| 44 |
+
"""Provider names currently switched on, in pipeline order."""
|
| 45 |
+
toggles = (
|
| 46 |
+
(sim_settings.enable_core, "core"),
|
| 47 |
+
(sim_settings.enable_arxiv, "arxiv"),
|
| 48 |
+
(sim_settings.enable_openalex, "openalex"),
|
| 49 |
+
(sim_settings.enable_crossref, "crossref"),
|
| 50 |
+
(sim_settings.enable_internal, "internal"),
|
| 51 |
+
)
|
| 52 |
+
return [name for on, name in toggles if on]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def _run_recheck(
|
| 56 |
+
job_id: str,
|
| 57 |
+
mongo: MongoService,
|
| 58 |
+
neo4j: Neo4jService,
|
| 59 |
+
embedder: VectorStoreService,
|
| 60 |
+
) -> None:
|
| 61 |
+
"""Re-run the similarity pipeline for a completed job's stored text."""
|
| 62 |
+
try:
|
| 63 |
+
job = await mongo.get_job(job_id)
|
| 64 |
+
doc_id = (job or {}).get("doc_id")
|
| 65 |
+
if not doc_id:
|
| 66 |
+
logger.warning(f"[{job_id}] recheck skipped: no doc_id")
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
rows = neo4j.run(
|
| 70 |
+
"MATCH (d:Document {doc_id: $doc_id})-[:HAS_SECTION]->(:Section)-[:HAS_PARAGRAPH]->(p:Paragraph) "
|
| 71 |
+
"RETURN p.text AS text ORDER BY p.position",
|
| 72 |
+
doc_id=doc_id,
|
| 73 |
+
)
|
| 74 |
+
if not rows:
|
| 75 |
+
rows = neo4j.run(
|
| 76 |
+
"MATCH (d:Document {doc_id: $doc_id})-[:HAS_SECTION]->(s:Section) "
|
| 77 |
+
"RETURN s.text AS text ORDER BY s.position",
|
| 78 |
+
doc_id=doc_id,
|
| 79 |
+
)
|
| 80 |
+
text = " ".join(r["text"] for r in rows if r.get("text"))
|
| 81 |
+
|
| 82 |
+
from agents.similarity_agent import build_default_providers
|
| 83 |
+
from similarity.pipeline import SimilarityPipeline
|
| 84 |
+
|
| 85 |
+
providers = build_default_providers(mongo, doc_id=doc_id, job_id=job_id)
|
| 86 |
+
pipeline = SimilarityPipeline(
|
| 87 |
+
providers=providers,
|
| 88 |
+
embedder=embedder if sim_settings.enable_paraphrase else None,
|
| 89 |
+
)
|
| 90 |
+
report = await pipeline.run(text, doc_id=doc_id)
|
| 91 |
+
payload = report.model_dump(mode="json")
|
| 92 |
+
|
| 93 |
+
# save_result merges with $set, so this refreshes just the similarity
|
| 94 |
+
# key without clobbering the rest of the analysis.
|
| 95 |
+
await mongo.save_result(job_id, {"similarity": payload})
|
| 96 |
+
logger.info(
|
| 97 |
+
f"[{job_id}] similarity recheck done | status={report.status} "
|
| 98 |
+
f"overall={report.overall_percent}%"
|
| 99 |
+
)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"[{job_id}] similarity recheck failed: {e}")
|
| 102 |
+
finally:
|
| 103 |
+
_recheck_in_flight.discard(job_id)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_recheck_runner():
|
| 107 |
+
"""Dependency returning the background recheck runner (overridable)."""
|
| 108 |
+
return _run_recheck
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get(
|
| 112 |
+
"/status",
|
| 113 |
+
response_model=SimilarityStatus,
|
| 114 |
+
summary="Which similarity providers are configured, corpus size, thresholds",
|
| 115 |
+
)
|
| 116 |
+
async def similarity_status(
|
| 117 |
+
mongo: MongoService = Depends(get_mongo),
|
| 118 |
+
) -> SimilarityStatus:
|
| 119 |
+
enabled = _enabled_providers()
|
| 120 |
+
corpus_size = 0
|
| 121 |
+
try:
|
| 122 |
+
db = mongo._get_db()
|
| 123 |
+
corpus_size = await db.corpus_fingerprints.count_documents({})
|
| 124 |
+
except Exception:
|
| 125 |
+
corpus_size = 0 # index not yet created -> nothing indexed
|
| 126 |
+
return SimilarityStatus(
|
| 127 |
+
enabled_providers=enabled,
|
| 128 |
+
any_enabled=bool(enabled),
|
| 129 |
+
corpus_size=corpus_size,
|
| 130 |
+
min_document_words=sim_settings.min_document_words,
|
| 131 |
+
min_match_words=sim_settings.min_match_words,
|
| 132 |
+
budget_seconds=sim_settings.budget_seconds,
|
| 133 |
+
paraphrase_enabled=sim_settings.enable_paraphrase,
|
| 134 |
+
paraphrase_threshold=sim_settings.paraphrase_threshold,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.post(
|
| 139 |
+
"/{job_id}/recheck",
|
| 140 |
+
response_model=RecheckAccepted,
|
| 141 |
+
status_code=202,
|
| 142 |
+
summary="Re-run the corpus-similarity report for a completed job",
|
| 143 |
+
)
|
| 144 |
+
async def recheck_similarity(
|
| 145 |
+
job_id: str,
|
| 146 |
+
background_tasks: BackgroundTasks,
|
| 147 |
+
mongo: MongoService = Depends(get_mongo),
|
| 148 |
+
neo4j: Neo4jService = Depends(get_neo4j),
|
| 149 |
+
embedder: VectorStoreService = Depends(get_embedder),
|
| 150 |
+
runner: Callable = Depends(get_recheck_runner),
|
| 151 |
+
) -> RecheckAccepted:
|
| 152 |
+
job = await mongo.get_job(job_id)
|
| 153 |
+
if not job:
|
| 154 |
+
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found.")
|
| 155 |
+
|
| 156 |
+
if job_id in _recheck_in_flight:
|
| 157 |
+
raise HTTPException(
|
| 158 |
+
status_code=409,
|
| 159 |
+
detail=f"Recheck for job '{job_id}' is already in flight.",
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
_recheck_in_flight.add(job_id)
|
| 163 |
+
background_tasks.add_task(runner, job_id, mongo, neo4j, embedder)
|
| 164 |
+
return RecheckAccepted(job_id=job_id)
|
backend/schemas/similarity.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for the corpus-similarity endpoints (plan.md §9).
|
| 3 |
+
|
| 4 |
+
The report body is `similarity.schema.SimilarityReport`, reused directly as
|
| 5 |
+
the response model the way text_detection reuses `EnsembleResult` - a parallel
|
| 6 |
+
copy would drift from the package it describes. Only the operator-facing status
|
| 7 |
+
view and the recheck acknowledgement live here.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SimilarityStatus(BaseModel):
|
| 14 |
+
"""Which providers are live and what thresholds the pipeline will use.
|
| 15 |
+
|
| 16 |
+
Directly modelled on `GET /text-detection/status`: it is how an operator
|
| 17 |
+
distinguishes "nothing configured" from "configured but failing".
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
enabled_providers: list[str] = Field(default_factory=list)
|
| 21 |
+
any_enabled: bool
|
| 22 |
+
corpus_size: int = 0
|
| 23 |
+
min_document_words: int
|
| 24 |
+
min_match_words: int
|
| 25 |
+
budget_seconds: int
|
| 26 |
+
paraphrase_enabled: bool
|
| 27 |
+
paraphrase_threshold: float
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class RecheckAccepted(BaseModel):
|
| 31 |
+
"""Acknowledgement that a recheck has been queued (plan.md §9)."""
|
| 32 |
+
|
| 33 |
+
job_id: str
|
| 34 |
+
status: str = "rechecking"
|
backend/tests/conftest.py
CHANGED
|
@@ -78,6 +78,10 @@ def mock_mongo():
|
|
| 78 |
mock_db.command = AsyncMock(return_value={"ok": 1})
|
| 79 |
mock_db.jobs = MagicMock()
|
| 80 |
mock_db.jobs.delete_one = AsyncMock()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
m._get_db = MagicMock(return_value=mock_db)
|
| 82 |
return m
|
| 83 |
|
|
|
|
| 78 |
mock_db.command = AsyncMock(return_value={"ok": 1})
|
| 79 |
mock_db.jobs = MagicMock()
|
| 80 |
mock_db.jobs.delete_one = AsyncMock()
|
| 81 |
+
mock_db.corpus_fingerprints = MagicMock()
|
| 82 |
+
mock_db.corpus_fingerprints.delete_many = AsyncMock()
|
| 83 |
+
mock_db.corpus_texts = MagicMock()
|
| 84 |
+
mock_db.corpus_texts.delete_many = AsyncMock()
|
| 85 |
m._get_db = MagicMock(return_value=mock_db)
|
| 86 |
return m
|
| 87 |
|
backend/tests/test_jobs.py
CHANGED
|
@@ -62,6 +62,14 @@ def test_delete_job(client):
|
|
| 62 |
assert resp.json()["deleted"] == "job_test123"
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def test_delete_job_not_found(client, mock_mongo):
|
| 66 |
mock_mongo.get_job = AsyncMock(return_value=None)
|
| 67 |
resp = client.delete("/api/jobs/nonexistent")
|
|
|
|
| 62 |
assert resp.json()["deleted"] == "job_test123"
|
| 63 |
|
| 64 |
|
| 65 |
+
def test_delete_job_purges_corpus_rows(client, mock_mongo):
|
| 66 |
+
db = mock_mongo._get_db()
|
| 67 |
+
resp = client.delete("/api/jobs/job_test123")
|
| 68 |
+
assert resp.status_code == 200
|
| 69 |
+
db.corpus_fingerprints.delete_many.assert_awaited_once()
|
| 70 |
+
db.corpus_texts.delete_many.assert_awaited_once()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
def test_delete_job_not_found(client, mock_mongo):
|
| 74 |
mock_mongo.get_job = AsyncMock(return_value=None)
|
| 75 |
resp = client.delete("/api/jobs/nonexistent")
|
backend/tests/test_similarity.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the corpus-similarity endpoints (plan.md §9, prompt Phase 4).
|
| 2 |
+
|
| 3 |
+
GET /reports/{job_id}/similarity -> 200 SimilarityReport | 202 | 404
|
| 4 |
+
POST /similarity/{job_id}/recheck -> 202 {job_id, status} | 409 in flight | 404
|
| 5 |
+
GET /similarity/status -> 200 SimilarityStatus
|
| 6 |
+
|
| 7 |
+
The recheck background task must not hit real providers in tests: the runner
|
| 8 |
+
is overridden through a dependency override, so only the 202/409/404 guard
|
| 9 |
+
behaviour is exercised here.
|
| 10 |
+
"""
|
| 11 |
+
import pytest
|
| 12 |
+
from unittest.mock import AsyncMock
|
| 13 |
+
|
| 14 |
+
from backend.routers.similarity import get_recheck_in_flight, get_recheck_runner
|
| 15 |
+
from similarity.schema import Bucket, Coverage, MatchSpan, SimilarityReport, SourceMatch
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _report_json() -> dict:
|
| 19 |
+
report = SimilarityReport(
|
| 20 |
+
overall_percent=42,
|
| 21 |
+
bucket_percents={Bucket.OPEN_ACCESS: 30, Bucket.PUBLICATION: 12},
|
| 22 |
+
sources=[
|
| 23 |
+
SourceMatch(
|
| 24 |
+
source_index=1,
|
| 25 |
+
bucket=Bucket.OPEN_ACCESS,
|
| 26 |
+
title="A real source",
|
| 27 |
+
url="https://example.org/paper",
|
| 28 |
+
doi="10.1000/x",
|
| 29 |
+
authors=["Alice", "Bob"],
|
| 30 |
+
year=2023,
|
| 31 |
+
provider="CORE",
|
| 32 |
+
display_label="https://example.org/paper",
|
| 33 |
+
matched_words=120,
|
| 34 |
+
percent=30,
|
| 35 |
+
spans=[
|
| 36 |
+
MatchSpan(
|
| 37 |
+
doc_start_word=0, doc_end_word=8, doc_char_start=0, doc_char_end=40,
|
| 38 |
+
word_count=8, excerpt="some shared text", source_excerpt="some shared text",
|
| 39 |
+
source_index=1,
|
| 40 |
+
)
|
| 41 |
+
],
|
| 42 |
+
)
|
| 43 |
+
],
|
| 44 |
+
coverage=Coverage(
|
| 45 |
+
total_words_compared=400,
|
| 46 |
+
words_excluded=10,
|
| 47 |
+
exclusions_applied=["bibliography"],
|
| 48 |
+
providers_queried=["CORE", "arXiv"],
|
| 49 |
+
phrases_queried=24,
|
| 50 |
+
candidates_retrieved=42,
|
| 51 |
+
candidates_verified=8,
|
| 52 |
+
internal_corpus_size=0,
|
| 53 |
+
min_match_words=8,
|
| 54 |
+
),
|
| 55 |
+
status="complete",
|
| 56 |
+
notes=[],
|
| 57 |
+
)
|
| 58 |
+
return report.model_dump(mode="json")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture(autouse=True)
|
| 62 |
+
def _clear_recheck_guard():
|
| 63 |
+
get_recheck_in_flight().clear()
|
| 64 |
+
yield
|
| 65 |
+
get_recheck_in_flight().clear()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.fixture
|
| 69 |
+
def stub_recheck_runner(client, mock_mongo, mock_neo4j):
|
| 70 |
+
"""Override the background recheck runner so no provider is hit."""
|
| 71 |
+
calls = []
|
| 72 |
+
|
| 73 |
+
async def fake_runner(job_id, mongo, neo4j, embedder):
|
| 74 |
+
calls.append(job_id)
|
| 75 |
+
|
| 76 |
+
client.app.dependency_overrides[get_recheck_runner] = lambda: fake_runner
|
| 77 |
+
yield calls
|
| 78 |
+
client.app.dependency_overrides.pop(get_recheck_runner, None)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ── GET /reports/{job_id}/similarity ─────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_similarity_report_served(client, mock_mongo):
|
| 85 |
+
mock_mongo.get_result = AsyncMock(return_value={"similarity": _report_json()})
|
| 86 |
+
resp = client.get("/api/reports/job_test123/similarity")
|
| 87 |
+
assert resp.status_code == 200
|
| 88 |
+
body = resp.json()
|
| 89 |
+
assert body["overall_percent"] == 42
|
| 90 |
+
assert body["status"] == "complete"
|
| 91 |
+
assert body["coverage"]["phrases_queried"] == 24
|
| 92 |
+
assert body["sources"][0]["provider"] == "CORE"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_similarity_report_202_when_job_pending(client, mock_mongo):
|
| 96 |
+
mock_mongo.get_result = AsyncMock(return_value=None)
|
| 97 |
+
mock_mongo.get_job = AsyncMock(return_value={"job_id": "job_test123", "status": "running"})
|
| 98 |
+
resp = client.get("/api/reports/job_test123/similarity")
|
| 99 |
+
assert resp.status_code == 202
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_similarity_report_404_unknown_job(client, mock_mongo):
|
| 103 |
+
mock_mongo.get_job = AsyncMock(return_value=None)
|
| 104 |
+
resp = client.get("/api/reports/nonexistent/similarity")
|
| 105 |
+
assert resp.status_code == 404
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ── POST /similarity/{job_id}/recheck ────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_recheck_accepted(client, stub_recheck_runner):
|
| 112 |
+
resp = client.post("/api/similarity/job_test123/recheck")
|
| 113 |
+
assert resp.status_code == 202
|
| 114 |
+
body = resp.json()
|
| 115 |
+
assert body["job_id"] == "job_test123"
|
| 116 |
+
assert body["status"] == "rechecking"
|
| 117 |
+
assert "job_test123" in stub_recheck_runner
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_recheck_409_when_already_in_flight(client, stub_recheck_runner, mock_mongo):
|
| 121 |
+
get_recheck_in_flight().add("job_test123")
|
| 122 |
+
resp = client.post("/api/similarity/job_test123/recheck")
|
| 123 |
+
assert resp.status_code == 409
|
| 124 |
+
assert "job_test123" not in stub_recheck_runner # no second storm launched
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_recheck_404_unknown_job(client, mock_mongo, stub_recheck_runner):
|
| 128 |
+
mock_mongo.get_job = AsyncMock(return_value=None)
|
| 129 |
+
resp = client.post("/api/similarity/nonexistent/recheck")
|
| 130 |
+
assert resp.status_code == 404
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ── GET /similarity/status ──────────────────���────────────────────────────────
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_status_shape(client):
|
| 137 |
+
resp = client.get("/api/similarity/status")
|
| 138 |
+
assert resp.status_code == 200
|
| 139 |
+
body = resp.json()
|
| 140 |
+
assert "enabled_providers" in body
|
| 141 |
+
assert "any_enabled" in body
|
| 142 |
+
assert "corpus_size" in body
|
| 143 |
+
assert "min_document_words" in body
|
| 144 |
+
assert "min_match_words" in body
|
| 145 |
+
assert "budget_seconds" in body
|
| 146 |
+
assert "paraphrase_enabled" in body
|
configs/pipeline_config.yaml
CHANGED
|
@@ -35,6 +35,10 @@ agents:
|
|
| 35 |
enabled: true
|
| 36 |
wave: 4
|
| 37 |
depends_on: [argumentation, citation_gap]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
report:
|
| 39 |
enabled: true
|
| 40 |
wave: 5
|
|
|
|
| 35 |
enabled: true
|
| 36 |
wave: 4
|
| 37 |
depends_on: [argumentation, citation_gap]
|
| 38 |
+
similarity:
|
| 39 |
+
enabled: true
|
| 40 |
+
wave: 3
|
| 41 |
+
depends_on: [parser, keyword]
|
| 42 |
report:
|
| 43 |
enabled: true
|
| 44 |
wave: 5
|
opencode-prompt.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenCode Implementation Prompt — Corpus Similarity Report
|
| 2 |
+
|
| 3 |
+
> Paste everything below the line into OpenCode as the opening prompt.
|
| 4 |
+
> Run it from the repo root (`IIT-Patna/`).
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Your task
|
| 9 |
+
|
| 10 |
+
> **State as of merge `7b89d44`:** Phases 0–4 are implemented, merged with the remote,
|
| 11 |
+
> and pushed. **Start at Phase 5.** All API routes now live under `/api/*`; read the
|
| 12 |
+
> post-merge amendments box at the top of `plan.md` before doing anything.
|
| 13 |
+
|
| 14 |
+
Implement the Corpus Similarity Report feature specified in `plan.md` in this
|
| 15 |
+
repository. Work phase by phase, test-first, committing after each phase.
|
| 16 |
+
|
| 17 |
+
`plan.md` is the **specification and the source of truth**. This prompt tells you how to
|
| 18 |
+
execute it. Where they conflict, `plan.md` wins on *what* and this prompt wins on *how*.
|
| 19 |
+
|
| 20 |
+
**Read these before writing any code, in this order:**
|
| 21 |
+
|
| 22 |
+
1. `plan.md` — the full spec. Read all 18 sections. Do not skim §4 (the scoring math) or
|
| 23 |
+
§7.4 (the privacy boundary); those are the two places a plausible-looking
|
| 24 |
+
implementation is most likely to be wrong.
|
| 25 |
+
2. `text_detection/` — the package you are copying the shape of. Read `config.py`,
|
| 26 |
+
`schema.py`, `pipeline.py`, `detectors/base.py`. Note: env-prefixed settings,
|
| 27 |
+
`extra="ignore"`, everything ships disabled, pipeline degrades instead of raising.
|
| 28 |
+
3. `services/paper_recommender.py` — the provider clients, rate-limit semaphore
|
| 29 |
+
(`_PROVIDER_SEM`), `STOPWORDS` / `FILLER_TOKENS`, `source_for()`, `DOI_PUBLISHER`.
|
| 30 |
+
You will import from this file. Do not copy from it.
|
| 31 |
+
4. `services/pdf_highlight_mapper.py` — the existing PDF span→coordinate mapper you will
|
| 32 |
+
extend in Phase 5.
|
| 33 |
+
5. `orchestrators/custom_orchestrator.py` and `workflows/hybrid_workflow.py` — how agents
|
| 34 |
+
are registered, how waves execute, what `critical=False` actually does.
|
| 35 |
+
6. `agents/base_agent.py` — the `BaseAgent` / `AgentContext` / `AgentResult` contract.
|
| 36 |
+
7. `backend/backend.md` — the API conventions you must match.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Stack facts you need
|
| 41 |
+
|
| 42 |
+
- Python 3.13, FastAPI, `pydantic` v2, `pydantic-settings`, `motor` (async Mongo),
|
| 43 |
+
`neo4j` (sync driver), `httpx`, `pytest` + `pytest-asyncio`.
|
| 44 |
+
- Frontend: React 18 + Vite, plain JSX (no TypeScript), one CSS file per page/component.
|
| 45 |
+
- Run the backend: `uvicorn backend.main:app --reload --port 8000`
|
| 46 |
+
- Run tests: `python -m pytest tests/similarity/ -v` and
|
| 47 |
+
`python -m pytest backend/tests/ -v`
|
| 48 |
+
- Build the frontend: `cd frontend-app && npm run build`
|
| 49 |
+
- Windows dev machine. Use `pathlib`, never string concatenation, for paths.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Non-negotiable constraints
|
| 54 |
+
|
| 55 |
+
Violating any of these means the work gets rejected regardless of whether it runs.
|
| 56 |
+
|
| 57 |
+
1. **No new runtime dependencies.** `requirements.txt` must not grow. Winnowing is
|
| 58 |
+
stdlib. If you believe you need a package, stop and ask — you almost certainly do not.
|
| 59 |
+
2. **TDD, genuinely.** Write the failing test, run it, watch it fail for the right
|
| 60 |
+
reason, then implement. A test written after the code is not a test, it is a
|
| 61 |
+
description. Minimum 80% coverage on the `similarity/` package.
|
| 62 |
+
3. **File size:** 200–400 lines typical, 800 hard maximum. Two files are already at the
|
| 63 |
+
edge — see "Traps" below.
|
| 64 |
+
4. **No mutation.** Build new objects; do not modify inputs in place. The scoring
|
| 65 |
+
functions in `similarity/aggregate.py` must be pure.
|
| 66 |
+
5. **Never swallow an error.** A provider that 429s goes into
|
| 67 |
+
`coverage.providers_failed`. A span that cannot be located gets logged and omitted,
|
| 68 |
+
never guessed. There is no bare `except: pass` anywhere in this feature.
|
| 69 |
+
6. **No LLM in the scoring path.** The percentage is computed arithmetically from
|
| 70 |
+
verified spans. `LLMService` must not be imported anywhere under `similarity/`.
|
| 71 |
+
7. **The privacy boundary in `plan.md` §7.4 is a hard requirement.** An internal-corpus
|
| 72 |
+
match must never carry the other document's title, URL, authors, owner, or text. This
|
| 73 |
+
is enforced at construction in `corpus/internal.py`, not filtered downstream. There is
|
| 74 |
+
a test for it; do not weaken the test.
|
| 75 |
+
8. **Never fabricate a source.** Every `SourceMatch.url` must be a real, resolvable URL
|
| 76 |
+
returned by a provider. A candidate without a URL is dropped, exactly as
|
| 77 |
+
`_score_candidates()` in `paper_recommender.py` already does.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Patterns to follow
|
| 82 |
+
|
| 83 |
+
| Need | Copy from | Not from |
|
| 84 |
+
|---|---|---|
|
| 85 |
+
| Package layout, settings, degradation | `text_detection/` | anywhere else |
|
| 86 |
+
| Env-prefixed settings class | `text_detection/config.py` (`TEXTDET_` → yours is `SIM_`) | `backend/config.py` |
|
| 87 |
+
| Async provider client + retry + throttle | `services/paper_recommender.py` (`@async_retry`, `_PROVIDER_SEM`) | write your own |
|
| 88 |
+
| Agent class shape | `agents/citation_gap_agent.py` (176 lines, `critical=False`, wave 3) | `agents/report_agent.py` (775 lines) |
|
| 89 |
+
| Router shape | `backend/routers/text_detection.py` | `backend/routers/reports.py` |
|
| 90 |
+
| Test fixtures / dependency overrides | `backend/tests/conftest.py` | write your own |
|
| 91 |
+
| Status endpoint | `GET /text-detection/status` | invent a new shape |
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## Execution order
|
| 96 |
+
|
| 97 |
+
Do these in order. **Commit at the end of each phase**, with a conventional-commit
|
| 98 |
+
message (`feat:`, `test:`, `refactor:`). Do not start a phase until the previous phase's
|
| 99 |
+
exit criteria pass.
|
| 100 |
+
|
| 101 |
+
### Phase 0 — Scoring core (no I/O)
|
| 102 |
+
|
| 103 |
+
Create `similarity/{__init__,config,schema,normalize,exclusions,fingerprint,selector,matcher,aggregate}.py`.
|
| 104 |
+
|
| 105 |
+
Everything here is a pure function over strings and lists. No network, no database, no
|
| 106 |
+
async. This is deliberate: it is the part that must be provably correct, so it must be
|
| 107 |
+
testable in milliseconds.
|
| 108 |
+
|
| 109 |
+
Implement in this order, each with its tests first:
|
| 110 |
+
|
| 111 |
+
1. `schema.py` — the Pydantic models exactly as written in `plan.md` §6.1. Copy the field
|
| 112 |
+
names verbatim; later phases and the frontend depend on them.
|
| 113 |
+
2. `config.py` — `SimilaritySettings(BaseSettings)` with `env_prefix="SIM_"`,
|
| 114 |
+
`extra="ignore"`, every field and default from `plan.md` §12. Module-level
|
| 115 |
+
`settings = SimilaritySettings()`, matching `text_detection/config.py`.
|
| 116 |
+
3. `normalize.py` — `normalize(text) -> NormalizedDoc` carrying the word list plus an
|
| 117 |
+
offset map back into the original string. **The offset map is what makes highlighting
|
| 118 |
+
land on the right pixels.** Test it by round-tripping every word.
|
| 119 |
+
4. `exclusions.py` — find bibliography, quoted text, title block, equations, captions;
|
| 120 |
+
return index ranges to exclude. Reuse `utils.pdf.split_into_sections` for the
|
| 121 |
+
bibliography — `ParserAgent` already isolates it.
|
| 122 |
+
5. `fingerprint.py` — k-gram hashing + winnowing. `k = settings.kgram_size` (5 words),
|
| 123 |
+
`w = settings.window_size` (4). The correctness test is a **property test**: generate
|
| 124 |
+
random text pairs sharing a run of ≥ `w+k-1` words, assert the fingerprint sets always
|
| 125 |
+
intersect. That guarantee is the foundation of every recall claim in the spec.
|
| 126 |
+
6. `selector.py` — pick the query phrases per `plan.md` §3 ("Choosing which phrases").
|
| 127 |
+
Import `STOPWORDS` and `FILLER_TOKENS` from `services.paper_recommender`.
|
| 128 |
+
7. `matcher.py` — fingerprint seeds → verified spans with exact word and char offsets.
|
| 129 |
+
Overlapping seeds merge into one span.
|
| 130 |
+
8. `aggregate.py` — the coverage bitmap and all four percentage calculations from
|
| 131 |
+
`plan.md` §4. This is the most important file in the feature.
|
| 132 |
+
|
| 133 |
+
**Exit criteria:**
|
| 134 |
+
- `python -m pytest tests/similarity/ -v` — all green, including the winnowing property
|
| 135 |
+
test.
|
| 136 |
+
- A document scored against itself returns exactly `100`.
|
| 137 |
+
- A document scored against unrelated text returns exactly `0`.
|
| 138 |
+
- `test_aggregate.py` covers every case listed in `plan.md` §13, especially: one span
|
| 139 |
+
claimed by three sources counts **once**.
|
| 140 |
+
|
| 141 |
+
### Phase 1 — Internal corpus
|
| 142 |
+
|
| 143 |
+
Create `similarity/corpus/{__init__,base}.py`, `similarity/corpus/internal.py`, and
|
| 144 |
+
`similarity/index_writer.py`.
|
| 145 |
+
|
| 146 |
+
- `base.py` — the `CorpusProvider` protocol and `Candidate` dataclass from `plan.md` §7.
|
| 147 |
+
- Mongo collections `corpus_fingerprints` and `corpus_texts` per `plan.md` §6.2. Create
|
| 148 |
+
the multikey index on `fingerprints` at startup — add it beside the existing index
|
| 149 |
+
creation in the orchestrator, not on every query.
|
| 150 |
+
- `index_writer.py` — gated on `settings.index_uploads`, which defaults to `False`.
|
| 151 |
+
- **Also in this phase:** extend `DELETE /jobs/{job_id}` in `backend/routers/jobs.py` to
|
| 152 |
+
purge that document's `corpus_fingerprints` and `corpus_texts` rows. `plan.md` §16
|
| 153 |
+
risk 6 requires this before the corpus can ever be enabled; build it now while the
|
| 154 |
+
write path is fresh, not later.
|
| 155 |
+
- Self-match guard: exclude by `doc_id`, and on recheck by `job_id`.
|
| 156 |
+
|
| 157 |
+
**Exit criteria:**
|
| 158 |
+
- Index document A, then score A against the corpus ⇒ ~100%.
|
| 159 |
+
- `tests/similarity/test_privacy.py` green: the returned `SourceMatch` for an internal hit
|
| 160 |
+
carries no title, URL, author, or text from the other document — only the neutral label
|
| 161 |
+
`CitationEdge Corpus · submitted YYYY-MM-DD`.
|
| 162 |
+
- Deleting a job removes its corpus rows.
|
| 163 |
+
|
| 164 |
+
### Phase 2 — External providers
|
| 165 |
+
|
| 166 |
+
Create `similarity/corpus/{core_api,arxiv,openalex,crossref}.py`.
|
| 167 |
+
|
| 168 |
+
- **CORE is the workhorse.** `GET https://api.core.ac.uk/v3/search/works` with
|
| 169 |
+
`q=fullText:"<phrase>"`. The response can carry `fullText`, so retrieval and text fetch
|
| 170 |
+
are one round trip. Send `Authorization: Bearer <SIM_CORE_API_KEY>` when the key is
|
| 171 |
+
set; log a startup warning when it is not (unregistered is ~5 requests / 10 seconds and
|
| 172 |
+
will rate-limit you immediately).
|
| 173 |
+
- arXiv / OpenAlex / Crossref: **import** `_search_arxiv`, `_search_openalex`,
|
| 174 |
+
`_search_crossref`, `source_for`, and `DOI_PUBLISHER` from
|
| 175 |
+
`services.paper_recommender`. If clean importing requires lifting them into a shared
|
| 176 |
+
`services/academic_search.py` that both modules import, do that refactor — it is
|
| 177 |
+
in-scope. Do not duplicate the functions.
|
| 178 |
+
- Every provider: shared `asyncio.Semaphore(2)`, stagger between calls, `@async_retry`
|
| 179 |
+
from `utils.retry`, and on persistent failure append to `coverage.providers_failed` and
|
| 180 |
+
return `[]`.
|
| 181 |
+
|
| 182 |
+
**Tests: no network.** Use `httpx.MockTransport` with fixtures covering happy path, 429,
|
| 183 |
+
5xx, malformed JSON, empty results, and a candidate with no resolvable URL.
|
| 184 |
+
|
| 185 |
+
**Exit criteria:** provider tests green; one manual run against a paper with a known arXiv
|
| 186 |
+
preprint locates it.
|
| 187 |
+
|
| 188 |
+
### Phase 3 — Pipeline and agent
|
| 189 |
+
|
| 190 |
+
Create `similarity/{pipeline,semantic}.py` and `agents/similarity_agent.py`.
|
| 191 |
+
|
| 192 |
+
- `pipeline.py` — orchestrates normalize → exclude → select → retrieve → verify → match →
|
| 193 |
+
aggregate → report. Wrap the whole retrieval stage in
|
| 194 |
+
`asyncio.wait_for(..., timeout=settings.budget_seconds)`; on timeout return what is
|
| 195 |
+
verified so far with `status="partial"` and `coverage.budget_exhausted=True`.
|
| 196 |
+
- **Implement the preprint self-match guard here** (`plan.md` §16 risk 2) — drop
|
| 197 |
+
candidates whose normalized title overlaps the uploaded title by ≥ 0.9 tokens, or whose
|
| 198 |
+
DOI matches, or whose authors overlap ≥ 50% while similarity > 60%. Record the drop in
|
| 199 |
+
`coverage.notes`. This needs its own test fixture. Skipping it produces a ~100% score on
|
| 200 |
+
every already-published paper.
|
| 201 |
+
- `semantic.py` — the paraphrase pass using the existing `VectorStoreService`. Assert in
|
| 202 |
+
a test that it **cannot** change `overall_percent`.
|
| 203 |
+
- `agents/similarity_agent.py` — `name = "similarity"`, `wave = 3`, `critical = False`.
|
| 204 |
+
Keep it under 100 lines: read paragraphs from Neo4j (reuse the query the orchestrator
|
| 205 |
+
already runs for AI-text detection), call the pipeline, write summary nodes, return the
|
| 206 |
+
dict.
|
| 207 |
+
- Wire into `orchestrators/custom_orchestrator.py`: add to `get_agents()`, read the
|
| 208 |
+
result off the `AgentResult` list, add `similarity` to `final_result`, `upsert_job`,
|
| 209 |
+
and `save_result`. `save_result` merges with `$set`, so adding a key is non-breaking.
|
| 210 |
+
- Add the `similarity` entry to `configs/pipeline_config.yaml` (wave 3,
|
| 211 |
+
`depends_on: [parser, keyword]`).
|
| 212 |
+
|
| 213 |
+
**Exit criteria:** a full analysis job writes `results.similarity`; simulating a CORE
|
| 214 |
+
outage yields `status="partial"` **and a completed job** — not a failed one.
|
| 215 |
+
|
| 216 |
+
### Phase 4 — API
|
| 217 |
+
|
| 218 |
+
- `GET /reports/{job_id}/similarity` in `backend/routers/reports.py`.
|
| 219 |
+
- New `backend/routers/similarity.py`: `POST /{job_id}/recheck` (202, `BackgroundTask`,
|
| 220 |
+
reuses stored normalized text — **no PDF re-parse**) and `GET /status`.
|
| 221 |
+
- New `backend/schemas/similarity.py` for the request/status models. Reuse
|
| 222 |
+
`similarity.schema.SimilarityReport` directly as the response model, the way
|
| 223 |
+
`text_detection` reuses `EnsembleResult` — do not mirror it.
|
| 224 |
+
- Register the router in `backend/main.py`.
|
| 225 |
+
- Recheck needs a `409` guard: an in-process set keyed by `job_id`, so four clicks do not
|
| 226 |
+
launch four concurrent 24-query provider storms.
|
| 227 |
+
|
| 228 |
+
**Exit criteria:** `backend/tests/test_similarity.py` green (report, 202 pending, 404
|
| 229 |
+
unknown, recheck accepted, recheck 409, status shape); endpoints visible at `/docs`.
|
| 230 |
+
|
| 231 |
+
### Phase 5 — Highlighting
|
| 232 |
+
|
| 233 |
+
Modify `services/pdf_highlight_mapper.py`.
|
| 234 |
+
|
| 235 |
+
- Generalize the hardcoded span typing. Today `_match_span()` does
|
| 236 |
+
`"type": "ai" if span.get("verdict") == "AI_GENERATED" else "human"` and `run()`
|
| 237 |
+
filters to `verdict in ("AI_GENERATED", "REAL")`. Add an optional `kind` on the input
|
| 238 |
+
span that passes through when present, plus an optional `meta` dict carrying
|
| 239 |
+
`source_index` for per-source colouring. **Default behaviour must not change** — the
|
| 240 |
+
existing AI-detection tests must pass untouched.
|
| 241 |
+
- Call the mapper **once**, not twice. It rasterizes into `pages_dir` and returns the full
|
| 242 |
+
`pages` array; two callers would race on the PNGs and produce two `page_highlights`
|
| 243 |
+
structures the frontend cannot merge. **The sole call site is
|
| 244 |
+
`agents/report_agent.py:327`** — not the orchestrator. `report` is wave 5 and
|
| 245 |
+
`similarity` is wave 3, so ReportAgent already has both span sets. Do not add a second
|
| 246 |
+
call site; extend the existing one with the similarity spans, each tagged with `kind`.
|
| 247 |
+
|
| 248 |
+
**Exit criteria:** similarity spans render as boxes on real page images; every existing
|
| 249 |
+
`pdf_highlight_mapper` test still passes with no edits.
|
| 250 |
+
|
| 251 |
+
### Phase 6 — Frontend
|
| 252 |
+
|
| 253 |
+
**Refactor first.** `frontend-app/src/pages/ReportPage.jsx` is **826 lines — already over
|
| 254 |
+
the 800-line cap**. Before adding anything:
|
| 255 |
+
|
| 256 |
+
- Move `buildReport()` and its helpers → `pages/reportAdapter.js`
|
| 257 |
+
- Move the `DEMO` constant → `pages/reportDemo.js`
|
| 258 |
+
- Verify the page still renders identically before continuing.
|
| 259 |
+
|
| 260 |
+
Then create:
|
| 261 |
+
|
| 262 |
+
- `components/SimilarityIndex.jsx` + `.css` — the header block: one large percentage,
|
| 263 |
+
three bucket figures. Brand rules from `DESIGN.md`: obsidian background, glass card,
|
| 264 |
+
Instrument Serif for the number, DM Mono for the buckets. **Not red.** Red is the error
|
| 265 |
+
colour in this brand; colouring a similarity score red makes an accusation the data
|
| 266 |
+
does not support.
|
| 267 |
+
- `components/SimilaritySources.jsx` — the numbered, colour-chipped source list. Internal
|
| 268 |
+
corpus rows render as plain text, never links.
|
| 269 |
+
- `components/CoverageNote.jsx` — the disclosure from `plan.md` §10.2, always visible,
|
| 270 |
+
never behind a toggle.
|
| 271 |
+
- `api.js` — `getSimilarity(jobId)`, `recheckSimilarity(jobId)`.
|
| 272 |
+
|
| 273 |
+
Wire into `ReportPage.jsx` in ~8 lines. Pass `kind === 'similarity'` highlights to the
|
| 274 |
+
existing page viewer.
|
| 275 |
+
|
| 276 |
+
**Exit criteria:** page renders the block; `ReportPage.jsx` back under 800 lines;
|
| 277 |
+
`status: "unavailable"` renders **no percentage at all**; `npm run build` clean.
|
| 278 |
+
|
| 279 |
+
### Phase 7 — PDF report section
|
| 280 |
+
|
| 281 |
+
- Create `agents/report_sections/similarity_section.py` exporting
|
| 282 |
+
`build_similarity_section(report: dict) -> list[Flowable]`.
|
| 283 |
+
- `agents/report_agent.py` is 775 lines — it gets a two-line import-and-append, nothing
|
| 284 |
+
more.
|
| 285 |
+
- Place the section after AI-text detection, before citation gaps.
|
| 286 |
+
- The coverage note must appear **verbatim** in the PDF. The PDF is the artefact that gets
|
| 287 |
+
emailed and attached to decisions; the disclosure has to travel with it.
|
| 288 |
+
- When similarity is `unavailable`, render a one-line "not available for this document"
|
| 289 |
+
note rather than omitting the section, so a reader can tell the check ran.
|
| 290 |
+
|
| 291 |
+
### Phase 8 — Evaluation
|
| 292 |
+
|
| 293 |
+
- `scripts/eval_similarity.py`, modelled on `scripts/eval_text_detection.py`.
|
| 294 |
+
- Build synthetic documents with known copy rates (0 / 5 / 10 / 25 / 50%) at run lengths
|
| 295 |
+
10 / 50 / 200 words, from CORE open-access papers.
|
| 296 |
+
- Tune `SIM_MIN_MATCH_WORDS` and the boilerplate stoplist against the 0% set until the
|
| 297 |
+
false-positive rate is **< 1%**, *then* measure mean absolute error. Precision first: an
|
| 298 |
+
inflated score on an honest paper is far worse than a missed match on a dishonest one.
|
| 299 |
+
- Write `similarity_eval_report.md` with the measured numbers. Do not write target numbers
|
| 300 |
+
as if they were results.
|
| 301 |
+
- Update `backend/backend.md` (new endpoints) and `README.md` (feature list).
|
| 302 |
+
|
| 303 |
+
---
|
| 304 |
+
|
| 305 |
+
## Traps — read before you start
|
| 306 |
+
|
| 307 |
+
These are real conditions in this repo that will bite you.
|
| 308 |
+
|
| 309 |
+
1. **`ReportPage.jsx` is 869 lines, already over the cap.** Adding to it without the
|
| 310 |
+
Phase 6 extraction makes a bad file worse.
|
| 311 |
+
2. **`report_agent.py` is 979 lines.** Well past the cap. The Phase 7 extraction is a
|
| 312 |
+
precondition for Phase 5 touching that file, not a later cleanup.
|
| 313 |
+
3. **`PdfHighlightMapper` rasterizes pages as a side effect.** Two callers race. One call
|
| 314 |
+
with a combined span list.
|
| 315 |
+
4. **`save_result()` merges with `$set`.** Adding `similarity` is safe; replacing the
|
| 316 |
+
document is not.
|
| 317 |
+
5. **`critical = True` aborts the pipeline.** Read `HybridWorkflow.execute` — a failed
|
| 318 |
+
critical agent breaks the wave loop. Similarity must be `critical = False` or a CORE
|
| 319 |
+
outage costs users their entire analysis.
|
| 320 |
+
6. **A published paper matches its own preprint at ~100%.** The Phase 3 guard is not
|
| 321 |
+
optional polish; without it the feature is unusable on real papers.
|
| 322 |
+
7. **CORE unauthenticated is ~5 requests / 10 seconds.** 24 phrase queries will 429
|
| 323 |
+
instantly. Throttle from the first line of code, not after you see the error.
|
| 324 |
+
8. **Bucket percentages do not sum to the headline number** (`plan.md` §4.4). This is
|
| 325 |
+
correct and matches Turnitin. Do not "fix" it.
|
| 326 |
+
9. **Scanned PDFs have no text layer** ⇒ no fingerprints ⇒ 0%, which reads as "clean" but
|
| 327 |
+
means "we could not look". `SIM_MIN_DOCUMENT_WORDS` must force `unavailable`.
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## Stop and ask if
|
| 332 |
+
|
| 333 |
+
- A phase's exit criteria cannot be met without changing the spec in `plan.md`.
|
| 334 |
+
- You believe a new dependency is required.
|
| 335 |
+
- The privacy boundary in §7.4 seems to conflict with a UI requirement.
|
| 336 |
+
- CORE's API shape differs from what `plan.md` §7.1 describes (the spec was written from
|
| 337 |
+
their docs; verify against the live API in Phase 2 and report any mismatch rather than
|
| 338 |
+
silently adapting).
|
| 339 |
+
- Measured false-positive rate in Phase 8 stays above 1% after calibration — that is a
|
| 340 |
+
product decision, not a tuning problem.
|
| 341 |
+
|
| 342 |
+
Do **not** stop to ask whether to continue between phases. Finish the phase, commit,
|
| 343 |
+
report the exit criteria you verified, and start the next one.
|
| 344 |
+
|
| 345 |
+
---
|
| 346 |
+
|
| 347 |
+
## Reporting
|
| 348 |
+
|
| 349 |
+
After each phase, report:
|
| 350 |
+
|
| 351 |
+
- Which exit criteria you verified, and the **actual command output** that proves it.
|
| 352 |
+
- Any file that grew past 400 lines, and why.
|
| 353 |
+
- Anything in `plan.md` that turned out to be wrong once you hit the real code.
|
| 354 |
+
|
| 355 |
+
Do not report a phase complete on the basis of code that looks right. Run the tests and
|
| 356 |
+
paste what they said. If something fails and you cannot fix it, say so plainly and move
|
| 357 |
+
to what you can finish.
|
orchestrators/custom_orchestrator.py
CHANGED
|
@@ -21,6 +21,7 @@ from agents import (
|
|
| 21 |
ArgumentationAgent,
|
| 22 |
ScoringAgent,
|
| 23 |
ReportAgent,
|
|
|
|
| 24 |
AgentContext,
|
| 25 |
AgentStatus,
|
| 26 |
)
|
|
@@ -76,6 +77,7 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 76 |
ArgumentationAgent(),
|
| 77 |
ScoringAgent(),
|
| 78 |
ReportAgent(),
|
|
|
|
| 79 |
]
|
| 80 |
|
| 81 |
async def run(
|
|
@@ -105,6 +107,14 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 105 |
except Exception as e:
|
| 106 |
logger.warning(f"Index creation skipped: {e}")
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
# Build shared context (passed to every agent)
|
| 109 |
graph_memory = GraphMemory(
|
| 110 |
neo4j=self.neo4j, embedder=self.embedder, vector_db=self.vector_db
|
|
@@ -138,6 +148,13 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 138 |
visual_figures = r.data["figures"]
|
| 139 |
break
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
# Build summary
|
| 142 |
duration = round(time.monotonic() - start, 2)
|
| 143 |
agent_summary = {
|
|
@@ -342,6 +359,7 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 342 |
"scores": scores,
|
| 343 |
"report_path": f"reports/{job_id}.pdf",
|
| 344 |
"ai_text_detection": ai_text_detection,
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
# Persist final status and all results
|
|
@@ -353,6 +371,7 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 353 |
"agents": agent_summary,
|
| 354 |
"scores": scores,
|
| 355 |
"ai_text_detection": ai_text_detection,
|
|
|
|
| 356 |
},
|
| 357 |
)
|
| 358 |
|
|
@@ -378,6 +397,7 @@ class CitationEdgeOrchestrator(BaseOrchestrator):
|
|
| 378 |
"citation_relevance": citrel_summary,
|
| 379 |
"irrelevant_citations": irrelevant_citations,
|
| 380 |
"ai_text_detection": ai_text_detection,
|
|
|
|
| 381 |
},
|
| 382 |
)
|
| 383 |
|
|
|
|
| 21 |
ArgumentationAgent,
|
| 22 |
ScoringAgent,
|
| 23 |
ReportAgent,
|
| 24 |
+
SimilarityAgent,
|
| 25 |
AgentContext,
|
| 26 |
AgentStatus,
|
| 27 |
)
|
|
|
|
| 77 |
ArgumentationAgent(),
|
| 78 |
ScoringAgent(),
|
| 79 |
ReportAgent(),
|
| 80 |
+
SimilarityAgent(),
|
| 81 |
]
|
| 82 |
|
| 83 |
async def run(
|
|
|
|
| 107 |
except Exception as e:
|
| 108 |
logger.warning(f"Index creation skipped: {e}")
|
| 109 |
|
| 110 |
+
# Ensure the internal-corpus fingerprint index (plan.md 6.2). Created
|
| 111 |
+
# once at startup, not on the per-query hot path.
|
| 112 |
+
try:
|
| 113 |
+
from similarity.index_writer import ensure_corpus_indexes
|
| 114 |
+
await ensure_corpus_indexes(self.mongo)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.warning(f"Corpus index creation skipped: {e}")
|
| 117 |
+
|
| 118 |
# Build shared context (passed to every agent)
|
| 119 |
graph_memory = GraphMemory(
|
| 120 |
neo4j=self.neo4j, embedder=self.embedder, vector_db=self.vector_db
|
|
|
|
| 148 |
visual_figures = r.data["figures"]
|
| 149 |
break
|
| 150 |
|
| 151 |
+
# Extract the corpus-similarity report from the SimilarityAgent result
|
| 152 |
+
similarity_report = None
|
| 153 |
+
for r in results:
|
| 154 |
+
if r.agent_name == "similarity" and r.data:
|
| 155 |
+
similarity_report = r.data
|
| 156 |
+
break
|
| 157 |
+
|
| 158 |
# Build summary
|
| 159 |
duration = round(time.monotonic() - start, 2)
|
| 160 |
agent_summary = {
|
|
|
|
| 359 |
"scores": scores,
|
| 360 |
"report_path": f"reports/{job_id}.pdf",
|
| 361 |
"ai_text_detection": ai_text_detection,
|
| 362 |
+
"similarity": similarity_report,
|
| 363 |
}
|
| 364 |
|
| 365 |
# Persist final status and all results
|
|
|
|
| 371 |
"agents": agent_summary,
|
| 372 |
"scores": scores,
|
| 373 |
"ai_text_detection": ai_text_detection,
|
| 374 |
+
"similarity": similarity_report,
|
| 375 |
},
|
| 376 |
)
|
| 377 |
|
|
|
|
| 397 |
"citation_relevance": citrel_summary,
|
| 398 |
"irrelevant_citations": irrelevant_citations,
|
| 399 |
"ai_text_detection": ai_text_detection,
|
| 400 |
+
"similarity": similarity_report,
|
| 401 |
},
|
| 402 |
)
|
| 403 |
|
plan.md
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Implementation Plan — Corpus Similarity Report ("Originality Report")
|
| 2 |
+
|
| 3 |
+
**Feature:** Turnitin-style similarity/originality reporting for uploaded papers.
|
| 4 |
+
**Status:** Phases 0–4 implemented; merged with remote `report-ui-updated` (merge commit
|
| 5 |
+
`506ad88`). Phases 5–8 outstanding.
|
| 6 |
+
**Date:** 2026-08-02 · revised after the merge
|
| 7 |
+
|
| 8 |
+
> ### Post-merge amendments (read before implementing Phases 5–8)
|
| 9 |
+
>
|
| 10 |
+
> The remote branch landed 15 commits while Phases 0–4 were being built. Four of them
|
| 11 |
+
> change assumptions in this document. The sections below have been corrected in place;
|
| 12 |
+
> this box is the summary.
|
| 13 |
+
>
|
| 14 |
+
> 1. **Every API route now lives under `/api`** (`d58fb43`). Each router carries its own
|
| 15 |
+
> prefix and the SPA is mounted at `/` with a client-side fallback, so any route
|
| 16 |
+
> *not* under `/api` is swallowed by the frontend. The similarity router was moved to
|
| 17 |
+
> `prefix="/api/similarity"`; §9 reflects the new paths.
|
| 18 |
+
> 2. **Wave 3 now runs eight agents**, not five — `citation_relevance` and `novelty`
|
| 19 |
+
> joined (`b54aebf`). The latency argument in §8.1 still holds (the wave is bounded by
|
| 20 |
+
> its slowest member and these are also network-bound), but the wave is busier than
|
| 21 |
+
> §8.1 originally described.
|
| 22 |
+
> 3. **`PdfHighlightMapper` is called from `agents/report_agent.py:327`, not the
|
| 23 |
+
> orchestrator.** §8.4 said the orchestrator should make the single combined call.
|
| 24 |
+
> That is now wrong and, happily, unnecessary: `report` is wave 5 and `similarity` is
|
| 25 |
+
> wave 3, so ReportAgent already has both span sets in hand. The "call it once"
|
| 26 |
+
> requirement is satisfied structurally — Phase 5 only has to pass similarity spans
|
| 27 |
+
> into the existing call site.
|
| 28 |
+
> 4. **`report_agent.py` is now 979 lines** (was 775) after the line-level AI-badge work
|
| 29 |
+
> (`7a1c4bd`, `f5f5aee`). It is well past the 800-line cap, so the Phase 7 extraction
|
| 30 |
+
> is no longer merely advisable — nothing new can go in that file until it is split.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Table of Contents
|
| 35 |
+
|
| 36 |
+
1. [What we are building](#1-what-we-are-building)
|
| 37 |
+
2. [Decisions already locked](#2-decisions-already-locked)
|
| 38 |
+
3. [Why this is hard (and how we get around it)](#3-why-this-is-hard-and-how-we-get-around-it)
|
| 39 |
+
4. [How the number is computed](#4-how-the-number-is-computed)
|
| 40 |
+
5. [Architecture — where every piece lives](#5-architecture--where-every-piece-lives)
|
| 41 |
+
6. [Data model](#6-data-model)
|
| 42 |
+
7. [Corpus providers](#7-corpus-providers)
|
| 43 |
+
8. [Integration into the existing pipeline](#8-integration-into-the-existing-pipeline)
|
| 44 |
+
9. [API surface](#9-api-surface)
|
| 45 |
+
10. [Frontend](#10-frontend)
|
| 46 |
+
11. [PDF report](#11-pdf-report)
|
| 47 |
+
12. [Configuration](#12-configuration)
|
| 48 |
+
13. [Testing strategy](#13-testing-strategy)
|
| 49 |
+
14. [Evaluation and calibration](#14-evaluation-and-calibration)
|
| 50 |
+
15. [Build order — phases with exit criteria](#15-build-order--phases-with-exit-criteria)
|
| 51 |
+
16. [Risks and mitigations](#16-risks-and-mitigations)
|
| 52 |
+
17. [What we are deliberately NOT building](#17-what-we-are-deliberately-not-building)
|
| 53 |
+
18. [Open questions](#18-open-questions)
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 1. What we are building
|
| 58 |
+
|
| 59 |
+
A report, attached to every analysis job, that answers: **which passages of this paper
|
| 60 |
+
already exist somewhere else, and where.**
|
| 61 |
+
|
| 62 |
+
Output shape, mirroring the reference screenshot:
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
Research_Proposal.pdf
|
| 66 |
+
CORPUS SIMILARITY REPORT
|
| 67 |
+
|
| 68 |
+
11% 10% 8% 8%
|
| 69 |
+
CORPUS OPEN ACCESS PUBLICATIONS CITATIONEDGE
|
| 70 |
+
SIMILARITY SOURCES CORPUS
|
| 71 |
+
|
| 72 |
+
PRIMARY SOURCES
|
| 73 |
+
[1] arxiv.org · Open Access 5%
|
| 74 |
+
[2] www.isca-archive.org · Open Access 2%
|
| 75 |
+
[3] "Text, Speech and Dialogue", Springer 2022 · Publication 1%
|
| 76 |
+
[4] CitationEdge Corpus · submitted 2026-03-14 1%
|
| 77 |
+
...
|
| 78 |
+
|
| 79 |
+
Checked against 412 candidate sources across 4 providers · 6,180 words compared
|
| 80 |
+
· quoted text and bibliography excluded · not a Turnitin-equivalent index
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Plus a **separate, non-headline** "Paraphrase Risk" signal, and **on-page highlighting**
|
| 84 |
+
of matched spans over the rasterized PDF (the viewer already exists — see §8.4).
|
| 85 |
+
|
| 86 |
+
### Why this feature belongs in CitationEdge
|
| 87 |
+
|
| 88 |
+
Per `PRODUCT.md`, the product promise is "depth over search" and "grounded output". A
|
| 89 |
+
similarity report is the natural sibling of the existing `citation_gap` agent: that one
|
| 90 |
+
says *"you should have cited something here"*, this one says *"this passage already
|
| 91 |
+
exists there"*. Both are claims about the paper's relationship to the literature, and
|
| 92 |
+
both must trace back to a real, linkable source. The two features share the same
|
| 93 |
+
provider layer, the same ranking intuition, and the same UI grammar.
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## 2. Decisions already locked
|
| 98 |
+
|
| 99 |
+
| # | Decision | Chosen | Why |
|
| 100 |
+
|---|---|---|---|
|
| 101 |
+
| 1 | Corpus | Hybrid: internal upload corpus + open academic full text | Free, no vendor lock-in, defensible provenance. Internal corpus gives duplicate-submission and self-plagiarism detection that no external API can. |
|
| 102 |
+
| 2 | Match method | Verbatim fingerprinting for the headline %, semantic embeddings as a separate signal | A percentage must mean "this fraction of words is copied". Cosine similarity cannot carry that meaning — every paper scores non-zero against its own field. |
|
| 103 |
+
| 3 | Framing | "Corpus Similarity" with mandatory coverage disclosure | We cannot index what Turnitin indexes. Shipping the number without the denominator would violate `PRODUCT.md` principle 2 (grounded output) and invites misconduct decisions on incomplete evidence. |
|
| 104 |
+
| 4 | Integration | Pipeline agent (wave 3, non-critical) + on-demand recheck endpoint | Fits the existing DAG exactly; recheck lets a report be refreshed as the corpus grows without re-parsing the PDF. |
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 3. Why this is hard (and how we get around it)
|
| 109 |
+
|
| 110 |
+
### The core problem
|
| 111 |
+
|
| 112 |
+
Turnitin works because it owns a crawled index of ~100 billion web pages plus licensed
|
| 113 |
+
publisher content plus ~2 billion student papers. We have none of that, and building it
|
| 114 |
+
is not a feature — it is a company.
|
| 115 |
+
|
| 116 |
+
**We cannot download the corpus. So we invert the problem: we let someone else's
|
| 117 |
+
inverted index do the retrieval, and we do the verification locally.**
|
| 118 |
+
|
| 119 |
+
### Two-stage retrieval
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
Stage 1 — RETRIEVAL (remote, cheap, recall-oriented)
|
| 123 |
+
Pick ~20-30 distinctive exact phrases from the paper.
|
| 124 |
+
Send each as a phrase query to CORE's Solr index:
|
| 125 |
+
GET https://api.core.ac.uk/v3/search/works?q=fullText:"<phrase>"
|
| 126 |
+
CORE searches 40M+ open-access full texts and returns the works
|
| 127 |
+
containing that literal phrase, WITH their full text in the response.
|
| 128 |
+
→ Yields a candidate set of maybe 50-400 documents.
|
| 129 |
+
|
| 130 |
+
Stage 2 — VERIFICATION (local, exact, precision-oriented)
|
| 131 |
+
For each candidate: winnowing fingerprint intersection → seed matches
|
| 132 |
+
→ greedy span extension → exact character offsets in both documents.
|
| 133 |
+
→ Yields defensible spans with real word counts.
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
This is the whole trick. Stage 1 costs a few HTTP calls and gives us recall against a
|
| 137 |
+
corpus we could never host. Stage 2 costs milliseconds of CPU and gives us the precision
|
| 138 |
+
that makes a percentage honest. **A phrase that appears in no candidate contributes 0% —
|
| 139 |
+
we never estimate, interpolate, or ask an LLM to guess.**
|
| 140 |
+
|
| 141 |
+
### Why phrase queries and not keyword queries
|
| 142 |
+
|
| 143 |
+
A keyword query (`fullText:"transformer attention"`) returns tens of thousands of
|
| 144 |
+
topically-related papers, none of which necessarily share a single sentence. A phrase
|
| 145 |
+
query for a distinctive 8-12 word run returns only documents that literally contain those
|
| 146 |
+
words. Recall is lower, but every hit is already close to a confirmed match, so the
|
| 147 |
+
verification stage does very little wasted work. This is the same reason
|
| 148 |
+
`paper_recommender.py` strips `FILLER_TOKENS` before querying — generic words return
|
| 149 |
+
generic results.
|
| 150 |
+
|
| 151 |
+
### Choosing which phrases to query
|
| 152 |
+
|
| 153 |
+
We cannot query every phrase in the paper (a 6,000-word paper has ~6,000 overlapping
|
| 154 |
+
8-grams). We select the most *distinctive* ones:
|
| 155 |
+
|
| 156 |
+
1. Drop phrases that are wholly inside excluded regions (bibliography, quotes, headers).
|
| 157 |
+
2. Drop phrases whose tokens are all in `STOPWORDS ∪ FILLER_TOKENS`
|
| 158 |
+
(reuse the lists already curated in `services/paper_recommender.py` — do not fork them).
|
| 159 |
+
3. Drop phrases matching the **boilerplate stoplist** (§16, risk 3):
|
| 160 |
+
"in this paper we propose", "the rest of this paper is organized as follows", etc.
|
| 161 |
+
4. Score remaining phrases by mean inverse token frequency *within the document* —
|
| 162 |
+
rare-in-this-paper words are usually rare globally too.
|
| 163 |
+
5. Spread the selection across sections so we don't query 30 phrases from the
|
| 164 |
+
introduction and none from the methods.
|
| 165 |
+
6. Take the top `SIM_MAX_QUERIES` (default 24).
|
| 166 |
+
|
| 167 |
+
Reasoning: a paper that copies will almost always copy a *contiguous run*. A stratified
|
| 168 |
+
sample of 24 distinctive phrases across the document has high probability of landing
|
| 169 |
+
inside any copied run longer than ~150 words. Copied runs shorter than that are caught
|
| 170 |
+
only if a sampled phrase happens to land in them — an acknowledged recall limit, and one
|
| 171 |
+
more reason the coverage disclosure is mandatory.
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## 4. How the number is computed
|
| 176 |
+
|
| 177 |
+
This section is the spec. Everything else is plumbing.
|
| 178 |
+
|
| 179 |
+
### 4.1 The denominator
|
| 180 |
+
|
| 181 |
+
```
|
| 182 |
+
total_words = words in the document AFTER exclusions
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
Excluded by default (each independently toggleable, mirroring Turnitin's options):
|
| 186 |
+
|
| 187 |
+
| Region | Excluded? | Why |
|
| 188 |
+
|---|---|---|
|
| 189 |
+
| Bibliography / references section | Yes | Reference strings are *supposed* to be identical to the original. Including them inflates every paper by 3-8%. `ParserAgent` already isolates this section. |
|
| 190 |
+
| Quoted text (`"..."`, block quotes) | Yes | Properly attributed quotation is not plagiarism. |
|
| 191 |
+
| Title / author / affiliation block | Yes | Institutional addresses match thousands of papers. |
|
| 192 |
+
| Equations, tables, figure captions | Yes | Notation collides across the whole field. |
|
| 193 |
+
| Everything else | No | This is the paper. |
|
| 194 |
+
|
| 195 |
+
### 4.2 The coverage bitmap
|
| 196 |
+
|
| 197 |
+
The single data structure the whole score rests on:
|
| 198 |
+
|
| 199 |
+
```python
|
| 200 |
+
covered: list[int | None] # one slot per word in the post-exclusion document
|
| 201 |
+
# value = index of the source that owns this word, or None
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
Every confirmed match writes its word range into the bitmap. **A word is counted once no
|
| 205 |
+
matter how many sources contain it** — this is what stops the percentages from exceeding
|
| 206 |
+
100% when a boilerplate sentence appears in forty papers.
|
| 207 |
+
|
| 208 |
+
```
|
| 209 |
+
overall_similarity_pct = round(100 * count(covered[i] is not None) / total_words)
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
### 4.3 Attribution — who gets credit for a word
|
| 213 |
+
|
| 214 |
+
When two sources both contain a span, the word is attributed to **the source with the
|
| 215 |
+
longest confirmed match overlapping that word**; ties break on higher provider trust
|
| 216 |
+
(internal corpus > CORE full text > publisher abstract). This is why per-source
|
| 217 |
+
percentages in the primary-sources list *approximately* sum to the headline index rather
|
| 218 |
+
than wildly exceeding it.
|
| 219 |
+
|
| 220 |
+
```
|
| 221 |
+
per_source_pct[s] = round(100 * count(covered[i] == s) / total_words)
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
### 4.4 Bucket percentages
|
| 225 |
+
|
| 226 |
+
The three bucket numbers (Open Access / Publications / CitationEdge Corpus) are **each
|
| 227 |
+
computed independently**, as "the similarity index you would get if this were the only
|
| 228 |
+
bucket". They deliberately do **not** sum to the headline number — in the reference
|
| 229 |
+
screenshot, 10 + 8 + 8 = 26 against an index of 11, for exactly this reason. Turnitin
|
| 230 |
+
behaves the same way; replicating it avoids surprising anyone who has read a real report.
|
| 231 |
+
|
| 232 |
+
```python
|
| 233 |
+
bucket_pct[b] = round(100 * count(word covered by ANY source in bucket b) / total_words)
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
Implementation: run the bitmap fill three more times, once per bucket, over the same
|
| 237 |
+
confirmed match list. Cheap (it is a pass over a list of integers), and much clearer than
|
| 238 |
+
trying to derive bucket numbers from the attributed bitmap.
|
| 239 |
+
|
| 240 |
+
### 4.5 Minimum match length
|
| 241 |
+
|
| 242 |
+
A match must be at least `SIM_MIN_MATCH_WORDS` (default **8**) consecutive words to enter
|
| 243 |
+
the bitmap. Below that, shared runs are linguistic coincidence, not reuse. This threshold
|
| 244 |
+
is the single biggest lever on the false-positive rate and must be exposed in config and
|
| 245 |
+
recorded in the report output so a result is reproducible.
|
| 246 |
+
|
| 247 |
+
### 4.6 The paraphrase signal (separate, never in the %)
|
| 248 |
+
|
| 249 |
+
Parallel pass using the existing `VectorStoreService` (SciBERT):
|
| 250 |
+
|
| 251 |
+
- Embed each body paragraph of the uploaded paper.
|
| 252 |
+
- Embed each candidate document's paragraphs (candidates are already in hand from stage 1
|
| 253 |
+
— no extra network cost).
|
| 254 |
+
- Flag pairs with cosine ≥ `SIM_PARAPHRASE_THRESHOLD` (default 0.92) **that are not
|
| 255 |
+
already verbatim-matched**.
|
| 256 |
+
|
| 257 |
+
Reported as a count and a list — "4 passages closely track a source without verbatim
|
| 258 |
+
overlap" — with the passage pairs shown side by side. It is a *reading prompt for a
|
| 259 |
+
human*, not a score, and it never touches the headline number. Reasoning: SciBERT cosine
|
| 260 |
+
between two paragraphs from the same subfield routinely hits 0.85 with zero copying; any
|
| 261 |
+
percentage built on that would be noise wearing a lab coat.
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## 5. Architecture — where every piece lives
|
| 266 |
+
|
| 267 |
+
The new code goes in a **self-contained `similarity/` package**, deliberately mirroring
|
| 268 |
+
the existing `text_detection/` package (config / schema / pipeline / pluggable backends,
|
| 269 |
+
everything env-toggled, degrades instead of blocking). That package is the house style
|
| 270 |
+
for "a bounded analytical capability the pipeline calls into", and copying its shape
|
| 271 |
+
means anyone who has read one can read the other.
|
| 272 |
+
|
| 273 |
+
```
|
| 274 |
+
similarity/
|
| 275 |
+
├── __init__.py
|
| 276 |
+
├── config.py # pydantic-settings, all SIM_* env vars (~80 lines)
|
| 277 |
+
├── schema.py # Pydantic models — the contract (~150)
|
| 278 |
+
├── normalize.py # canonicalization + offset map (~120)
|
| 279 |
+
├── exclusions.py # bibliography / quotes / boilerplate regions (~180)
|
| 280 |
+
├── fingerprint.py # k-gram hashing + winnowing (~130)
|
| 281 |
+
├── selector.py # pick distinctive query phrases (~140)
|
| 282 |
+
├── matcher.py # fingerprint seeds → verified spans (~200)
|
| 283 |
+
├── aggregate.py # coverage bitmap → percentages + buckets (~180)
|
| 284 |
+
├── semantic.py # SciBERT paraphrase pass (~120)
|
| 285 |
+
├── pipeline.py # orchestrates all of the above (~220)
|
| 286 |
+
├── index_writer.py # write this doc into the internal corpus (~110)
|
| 287 |
+
└── corpus/
|
| 288 |
+
├── __init__.py
|
| 289 |
+
├── base.py # CorpusProvider protocol + Candidate dataclass (~90)
|
| 290 |
+
├── core_api.py # CORE v3 — the full-text workhorse (~170)
|
| 291 |
+
├── openalex.py # abstract-level matching (~90)
|
| 292 |
+
├── arxiv.py # abstract + OA PDF fetch (~110)
|
| 293 |
+
├── crossref.py # publisher metadata / abstracts (~90)
|
| 294 |
+
└── internal.py # prior CitationEdge uploads (~190)
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
Every file lands well inside the 800-line cap and most inside the 200-400 "typical" band
|
| 298 |
+
from the coding-style rules.
|
| 299 |
+
|
| 300 |
+
### Files modified (not created)
|
| 301 |
+
|
| 302 |
+
| File | Current | Change | Note |
|
| 303 |
+
|---|---|---|---|
|
| 304 |
+
| `agents/similarity_agent.py` | — | **new**, ~90 lines | Thin adapter: pull text from Neo4j → call pipeline → write Neo4j + return dict. All logic lives in `similarity/`. |
|
| 305 |
+
| `agents/__init__.py` | | export `SimilarityAgent` | |
|
| 306 |
+
| `orchestrators/custom_orchestrator.py` | 359 | +~40 | Register agent; fetch similarity rows; add to `final_result` / `save_result`. |
|
| 307 |
+
| `configs/pipeline_config.yaml` | 39 | +4 | Declare `similarity` at wave 3, `depends_on: [parser, keyword]`. |
|
| 308 |
+
| `services/pdf_highlight_mapper.py` | 307 | ~+30 | Generalize hardcoded `"ai"` / `"human"` typing — see §8.4. |
|
| 309 |
+
| `backend/routers/reports.py` | 478 | +~45 | `GET /reports/{id}/similarity`. |
|
| 310 |
+
| `backend/routers/similarity.py` | — | **new**, ~110 | `POST /{id}/recheck`, `GET /status`. |
|
| 311 |
+
| `backend/schemas/similarity.py` | — | **new**, ~40 | Request + status models; reuse `similarity.schema` for the response, exactly as `text_detection` does. |
|
| 312 |
+
| `backend/main.py` | 288 | +2 | Register router (done). |
|
| 313 |
+
| `agents/report_agent.py` | **979** | **extract, then +2** | Grew from 775 to 979 in the merge; far past the cap and cannot absorb a new section. Extract the similarity section into `agents/report_sections/similarity_section.py`. |
|
| 314 |
+
| `frontend-app/src/pages/ReportPage.jsx` | **869** | **−~300, +~8** | **Already over the 800 cap.** Extract `buildReport()` and `DEMO` into `pages/reportAdapter.js` / `pages/reportDemo.js` first, then add the similarity block as 8 lines calling new components. |
|
| 315 |
+
| `frontend-app/src/components/SimilarityIndex.jsx` + `.css` | — | **new**, ~160 | Header block: big %, three buckets. |
|
| 316 |
+
| `frontend-app/src/components/SimilaritySources.jsx` | — | **new**, ~140 | Numbered, colour-coded primary sources. |
|
| 317 |
+
| `frontend-app/src/components/CoverageNote.jsx` | — | **new**, ~60 | The disclosure. |
|
| 318 |
+
| `frontend-app/src/api.js` | 103 | +~15 | `getSimilarity`, `recheckSimilarity`. |
|
| 319 |
+
| `requirements.txt` | 56 | +0 | **No new dependencies.** `httpx`, `numpy`, `pymupdf`, `transformers` are all present. |
|
| 320 |
+
|
| 321 |
+
**Zero new runtime dependencies** is a deliberate design constraint: the fingerprinting is
|
| 322 |
+
~130 lines of standard-library Python, and adding a plagiarism SDK would undo the
|
| 323 |
+
"no vendor lock-in" decision from §2.
|
| 324 |
+
|
| 325 |
+
---
|
| 326 |
+
|
| 327 |
+
## 6. Data model
|
| 328 |
+
|
| 329 |
+
### 6.1 Pydantic schema (`similarity/schema.py`)
|
| 330 |
+
|
| 331 |
+
```python
|
| 332 |
+
class Bucket(str, Enum):
|
| 333 |
+
OPEN_ACCESS = "open_access" # CORE, arXiv — the "Internet Sources" analogue
|
| 334 |
+
PUBLICATION = "publication" # Crossref/DOI-bearing, publisher-hosted
|
| 335 |
+
INTERNAL = "internal" # prior CitationEdge uploads
|
| 336 |
+
|
| 337 |
+
class MatchSpan(BaseModel):
|
| 338 |
+
doc_start_word: int # inclusive, index into post-exclusion word list
|
| 339 |
+
doc_end_word: int # exclusive
|
| 340 |
+
doc_char_start: int # offset into ORIGINAL text — drives highlighting
|
| 341 |
+
doc_char_end: int
|
| 342 |
+
word_count: int
|
| 343 |
+
excerpt: str # <= 300 chars, for display
|
| 344 |
+
source_excerpt: str # the matching text in the source
|
| 345 |
+
source_index: int # 1-based, matches the numbered UI chips
|
| 346 |
+
|
| 347 |
+
class SourceMatch(BaseModel):
|
| 348 |
+
source_index: int
|
| 349 |
+
bucket: Bucket
|
| 350 |
+
title: str
|
| 351 |
+
url: str # ALWAYS resolvable — a source you cannot open
|
| 352 |
+
# is not evidence. Candidates without a URL are
|
| 353 |
+
# dropped, same rule as _score_candidates().
|
| 354 |
+
doi: str | None
|
| 355 |
+
authors: list[str] = []
|
| 356 |
+
year: int | None
|
| 357 |
+
provider: str # "CORE" | "arXiv" | "Crossref" | "OpenAlex" | "CitationEdge Corpus"
|
| 358 |
+
display_label: str # "arxiv.org" / '"Text, Speech and Dialogue", Springer, 2022'
|
| 359 |
+
matched_words: int # attributed words only (§4.3)
|
| 360 |
+
percent: int # matched_words / total_words * 100, rounded
|
| 361 |
+
spans: list[MatchSpan]
|
| 362 |
+
|
| 363 |
+
class ParaphraseFlag(BaseModel):
|
| 364 |
+
doc_excerpt: str
|
| 365 |
+
source_excerpt: str
|
| 366 |
+
source_index: int
|
| 367 |
+
cosine: float
|
| 368 |
+
|
| 369 |
+
class Coverage(BaseModel):
|
| 370 |
+
"""Mandatory disclosure — the denominator behind the number."""
|
| 371 |
+
total_words_compared: int
|
| 372 |
+
words_excluded: int
|
| 373 |
+
exclusions_applied: list[str] # ["bibliography", "quotes", "title_block", ...]
|
| 374 |
+
providers_queried: list[str]
|
| 375 |
+
providers_failed: list[str] # a 429 from CORE must be visible, never silent
|
| 376 |
+
phrases_queried: int
|
| 377 |
+
candidates_retrieved: int
|
| 378 |
+
candidates_verified: int
|
| 379 |
+
internal_corpus_size: int
|
| 380 |
+
min_match_words: int
|
| 381 |
+
budget_exhausted: bool # True if we stopped early on the time budget
|
| 382 |
+
checked_at: datetime
|
| 383 |
+
|
| 384 |
+
class SimilarityReport(BaseModel):
|
| 385 |
+
overall_percent: int
|
| 386 |
+
bucket_percents: dict[Bucket, int]
|
| 387 |
+
sources: list[SourceMatch] # sorted by percent desc; the "primary sources" list
|
| 388 |
+
paraphrase_flags: list[ParaphraseFlag]
|
| 389 |
+
coverage: Coverage
|
| 390 |
+
status: Literal["complete", "partial", "unavailable"]
|
| 391 |
+
notes: list[str] # human-readable caveats surfaced in the UI
|
| 392 |
+
processing_time_ms: float
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
`status` semantics, following the `text_detection` precedent of degrading rather than
|
| 396 |
+
blocking:
|
| 397 |
+
|
| 398 |
+
- `complete` — all configured providers answered.
|
| 399 |
+
- `partial` — at least one provider failed or the time budget was exhausted. The number
|
| 400 |
+
is a **lower bound**; the UI must say so.
|
| 401 |
+
- `unavailable` — no provider answered, or the document was too short
|
| 402 |
+
(`< SIM_MIN_DOCUMENT_WORDS`, default 300). **No number is shown.** We never render a
|
| 403 |
+
0% that means "we didn't look".
|
| 404 |
+
|
| 405 |
+
### 6.2 Persistence
|
| 406 |
+
|
| 407 |
+
| Store | Collection / label | Contents | Why there |
|
| 408 |
+
|---|---|---|---|
|
| 409 |
+
| MongoDB | `results.similarity` | Full `SimilarityReport` dict | Same place every other agent output lives; `GET /reports/{id}/json` picks it up for free via the existing `$set` merge in `save_result`. |
|
| 410 |
+
| MongoDB | `corpus_fingerprints` | `{doc_id, job_id, title, owner_email, word_count, fingerprints: [int], created_at}` | The internal corpus inverted index. Multikey index on `fingerprints` makes candidate lookup a single indexed query. |
|
| 411 |
+
| MongoDB | `corpus_texts` | `{doc_id, normalized_text, offset_map}` | Needed to verify and excerpt an internal match. Separate collection so the fingerprint index stays small and hot. |
|
| 412 |
+
| Neo4j | `(:Document)-[:HAS_SIMILARITY]->(:SimilarityReport)` and `(:SimilarityReport)-[:MATCHES]->(:SimilaritySource)` | Summary properties only | Keeps the graph the primary thinking surface (`PRODUCT.md` principle 4) — you can traverse from a paper to the works it overlaps. Full spans stay in Mongo; the graph is not a document store. |
|
| 413 |
+
| LanceDB | table `corpus_paragraphs` | Paragraph embeddings for the paraphrase pass | `LanceDBService` already exists and is already wired into `AgentContext.vector_db`. |
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## 7. Corpus providers
|
| 418 |
+
|
| 419 |
+
All providers implement one protocol so the pipeline is provider-agnostic and each can be
|
| 420 |
+
switched off independently:
|
| 421 |
+
|
| 422 |
+
```python
|
| 423 |
+
class CorpusProvider(Protocol):
|
| 424 |
+
name: str
|
| 425 |
+
bucket: Bucket
|
| 426 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]: ...
|
| 427 |
+
async def fetch_text(self, candidate: Candidate) -> str | None: ...
|
| 428 |
+
```
|
| 429 |
+
|
| 430 |
+
### 7.1 CORE — the workhorse
|
| 431 |
+
|
| 432 |
+
- Endpoint: `GET https://api.core.ac.uk/v3/search/works?q=fullText:"<phrase>"`
|
| 433 |
+
- Coverage: ~300M metadata records, **40M+ full texts** — the largest open-access
|
| 434 |
+
aggregation available.
|
| 435 |
+
- Auth: free; works unauthenticated but **register for an API key** — the unregistered
|
| 436 |
+
limit is roughly 5 single requests / 10 seconds, which 24 phrase queries would blow
|
| 437 |
+
through in the first two seconds.
|
| 438 |
+
- Crucially, the response can carry `fullText`, so retrieval and text-fetch collapse into
|
| 439 |
+
one round trip.
|
| 440 |
+
- Handling: shared `asyncio.Semaphore(2)` plus a stagger, exactly like `_PROVIDER_SEM` in
|
| 441 |
+
`paper_recommender.py`; `@async_retry` on 429/5xx; on persistent failure record it in
|
| 442 |
+
`coverage.providers_failed` and continue.
|
| 443 |
+
|
| 444 |
+
### 7.2 arXiv
|
| 445 |
+
|
| 446 |
+
- Reuse `_search_arxiv()` from `services/paper_recommender.py` verbatim for metadata.
|
| 447 |
+
- For candidates that survive abstract-level screening, fetch the OA PDF and extract text
|
| 448 |
+
with the existing `utils.pdf.extract_text_from_pdf`. Cap at `SIM_MAX_PDF_FETCHES`
|
| 449 |
+
(default 5) — PDF fetch and parse is the slowest thing in the pipeline.
|
| 450 |
+
- Bucket: `OPEN_ACCESS`.
|
| 451 |
+
|
| 452 |
+
### 7.3 OpenAlex and Crossref
|
| 453 |
+
|
| 454 |
+
- Reuse `_search_openalex()` and `_search_crossref()` unchanged.
|
| 455 |
+
- **Abstracts only** — neither serves full text. An abstract-level match is real but
|
| 456 |
+
small, so these mostly populate the `PUBLICATION` bucket with 1% entries, which is
|
| 457 |
+
exactly what the reference screenshot shows for Springer.
|
| 458 |
+
- `source_for()` and the `DOI_PUBLISHER` prefix map already turn a DOI into
|
| 459 |
+
"Elsevier" / "Springer" / "IEEE" — reuse directly for `display_label`.
|
| 460 |
+
|
| 461 |
+
**DRY note:** these three provider functions are imported from `paper_recommender.py`,
|
| 462 |
+
never copied. If that means lifting them into a shared `services/academic_search.py` that
|
| 463 |
+
both modules import, do that refactor as part of Phase 2 — it is a targeted improvement
|
| 464 |
+
to code we are already working in, not unrelated cleanup.
|
| 465 |
+
|
| 466 |
+
### 7.4 Internal corpus
|
| 467 |
+
|
| 468 |
+
The only bucket where we own the index, and the only one that can catch a paper
|
| 469 |
+
submitted twice.
|
| 470 |
+
|
| 471 |
+
**Write path** (`similarity/index_writer.py`, called at the end of a successful job when
|
| 472 |
+
`SIM_INDEX_UPLOADS=true`): normalize → fingerprint → insert into `corpus_fingerprints` +
|
| 473 |
+
`corpus_texts` → upsert paragraph embeddings into LanceDB.
|
| 474 |
+
|
| 475 |
+
**Read path:** fingerprint the new document → query
|
| 476 |
+
`corpus_fingerprints.find({fingerprints: {$in: doc_fingerprints}})` → the multikey index
|
| 477 |
+
returns only documents sharing at least one fingerprint → fetch their normalized text →
|
| 478 |
+
verify locally. No embeddings needed for the verbatim path; this is exact and fast.
|
| 479 |
+
|
| 480 |
+
**Privacy — this is not optional.** Another researcher's uploaded paper is confidential.
|
| 481 |
+
The report may show:
|
| 482 |
+
|
| 483 |
+
- that a match exists,
|
| 484 |
+
- the matched **excerpt from the user's own document**,
|
| 485 |
+
- a neutral label: `CitationEdge Corpus · submitted 2026-03-14`,
|
| 486 |
+
- the percentage.
|
| 487 |
+
|
| 488 |
+
The report must **never** expose the other document's title, authors, owner, full text, or
|
| 489 |
+
a link to it. This mirrors how Turnitin handles student-paper matches, and it is the
|
| 490 |
+
behaviour that lets us keep the bucket at all. Enforcement: `SourceMatch.title` and
|
| 491 |
+
`.url` are set to the neutral label for `Bucket.INTERNAL`, in `internal.py`, at
|
| 492 |
+
construction — not filtered later in the UI where a future refactor could drop the filter.
|
| 493 |
+
|
| 494 |
+
Self-match guard: a document must never match itself. Exclude by `doc_id`, and on
|
| 495 |
+
recheck, by `job_id` too.
|
| 496 |
+
|
| 497 |
+
---
|
| 498 |
+
|
| 499 |
+
## 8. Integration into the existing pipeline
|
| 500 |
+
|
| 501 |
+
### 8.1 The agent
|
| 502 |
+
|
| 503 |
+
`agents/similarity_agent.py`:
|
| 504 |
+
|
| 505 |
+
```python
|
| 506 |
+
class SimilarityAgent(BaseAgent):
|
| 507 |
+
name = "similarity"
|
| 508 |
+
wave = 3 # after parser (w1) and keyword (w2)
|
| 509 |
+
critical = False # a similarity failure must never fail an analysis
|
| 510 |
+
```
|
| 511 |
+
|
| 512 |
+
**Why wave 3.** It needs paragraphs from `ParserAgent` (wave 1) and benefits from
|
| 513 |
+
`KeywordAgent` output (wave 2) for query construction and domain-token scoring. Wave 3
|
| 514 |
+
already runs five agents in parallel — `citation_gap`, `claim_verifier`,
|
| 515 |
+
`counter_factuality`, `evidence_grounding`, `argumentation` — and all but the last are
|
| 516 |
+
already blocking on external HTTP. `HybridWorkflow` runs a wave via `ParallelWorkflow`,
|
| 517 |
+
so the added wall-clock cost is `max(0, similarity_time − current_wave3_time)`, which for
|
| 518 |
+
a 45-second budget against agents that already take that long is close to zero.
|
| 519 |
+
|
| 520 |
+
**Why `critical = False`.** Look at `HybridWorkflow.execute`: a failed critical agent
|
| 521 |
+
breaks the wave loop and aborts the pipeline. A CORE outage must not cost the user their
|
| 522 |
+
claim verification, citation gaps, and PDF report. Non-critical failure degrades the job
|
| 523 |
+
to `partial_failure` and the rest of the analysis completes — the same choice
|
| 524 |
+
`citation_gap`, `claim_verifier`, `counter_factuality`, and `evidence_grounding` already
|
| 525 |
+
make.
|
| 526 |
+
|
| 527 |
+
The agent itself stays thin (~90 lines): read paragraphs from Neo4j (the same query the
|
| 528 |
+
orchestrator already uses for AI-text detection), call
|
| 529 |
+
`SimilarityPipeline.run(text, doc_id, keywords)`, write summary nodes to Neo4j, return
|
| 530 |
+
the report dict. All algorithmic work lives in `similarity/` where it can be unit-tested
|
| 531 |
+
without a database.
|
| 532 |
+
|
| 533 |
+
### 8.2 Time budget
|
| 534 |
+
|
| 535 |
+
A hard `asyncio.wait_for` wrapping the whole retrieval stage at `SIM_BUDGET_SECONDS`
|
| 536 |
+
(default 45). On timeout the pipeline returns whatever it has verified so far with
|
| 537 |
+
`status="partial"` and `coverage.budget_exhausted=True`. Partial evidence, honestly
|
| 538 |
+
labelled, beats a hung job — and this is what makes it safe to put a network-bound step
|
| 539 |
+
inside a user-facing pipeline at all.
|
| 540 |
+
|
| 541 |
+
### 8.3 Orchestrator wiring
|
| 542 |
+
|
| 543 |
+
In `custom_orchestrator.py`, following the exact pattern already used for
|
| 544 |
+
`counterfactuality` and `ai_text_detection`:
|
| 545 |
+
|
| 546 |
+
1. Add `SimilarityAgent()` to `get_agents()`.
|
| 547 |
+
2. After the workflow returns, read the similarity result off the `AgentResult` list
|
| 548 |
+
(cleaner than a Neo4j round trip, and the report is a document not a graph).
|
| 549 |
+
3. Add `similarity` to `final_result`, to `upsert_job`, and to `save_result`.
|
| 550 |
+
|
| 551 |
+
Note that `save_result` merges with `$set`, so adding a key is non-breaking for existing
|
| 552 |
+
consumers.
|
| 553 |
+
|
| 554 |
+
### 8.4 Highlighting — reuse, with one required change
|
| 555 |
+
|
| 556 |
+
`services/pdf_highlight_mapper.py` already does the hard part: PyMuPDF word extraction,
|
| 557 |
+
normalized substring location, multi-line box grouping, 0-1 coordinate normalization, 2x
|
| 558 |
+
page rasterization, and graceful handling of unmatched spans and missing text layers.
|
| 559 |
+
`ReportPage.jsx` already renders those boxes over page images served by
|
| 560 |
+
`GET /jobs/{id}/pages/{n}.png`. **We should not build a second highlighting system.**
|
| 561 |
+
|
| 562 |
+
Two changes are required:
|
| 563 |
+
|
| 564 |
+
1. **Generalize the span type.** `_match_span()` hardcodes
|
| 565 |
+
`"type": "ai" if span.get("verdict") == "AI_GENERATED" else "human"`, and `run()`
|
| 566 |
+
filters to `verdict in ("AI_GENERATED", "REAL")`. Add an optional `kind` field on the
|
| 567 |
+
input span that passes through when present, plus an optional `meta` dict (carrying
|
| 568 |
+
`source_index` so the frontend can colour-code by source the way the reference
|
| 569 |
+
screenshot numbers and colours each entry). Default behaviour unchanged — existing
|
| 570 |
+
AI-detection callers and their tests keep working.
|
| 571 |
+
|
| 572 |
+
2. **Call the mapper once, not twice.** It rasterizes into `pages_dir` and returns a full
|
| 573 |
+
`pages` array. Two independent callers would either race on the PNGs or produce two
|
| 574 |
+
`page_highlights` structures that the frontend cannot merge.
|
| 575 |
+
|
| 576 |
+
**Corrected after the merge:** the sole caller is `agents/report_agent.py:327`, not the
|
| 577 |
+
orchestrator as this section originally assumed. That is the easier situation —
|
| 578 |
+
`report` is wave 5 and `similarity` is wave 3, so ReportAgent already has both the AI
|
| 579 |
+
spans and the similarity report available when it builds the call. Phase 5 therefore
|
| 580 |
+
does not introduce a second call site; it extends the existing one with the similarity
|
| 581 |
+
spans, each tagged with its `kind`. The frontend then filters by `kind` to toggle
|
| 582 |
+
"AI detection" and "Similarity" overlay layers independently.
|
| 583 |
+
|
| 584 |
+
This is a real integration cost and it is the reason to do highlighting in its own phase
|
| 585 |
+
(Phase 5) rather than smuggling it into the agent phase.
|
| 586 |
+
|
| 587 |
+
---
|
| 588 |
+
|
| 589 |
+
## 9. API surface
|
| 590 |
+
|
| 591 |
+
Following the conventions in `backend/backend.md`. **All routes sit under `/api`** — the
|
| 592 |
+
SPA is mounted at `/` with a catch-all fallback, so a route outside `/api` returns the
|
| 593 |
+
frontend's HTML instead of JSON. Each router declares its own prefix; there is no
|
| 594 |
+
app-level prefix to inherit.
|
| 595 |
+
|
| 596 |
+
| Method | Path | Response | Notes |
|
| 597 |
+
|---|---|---|---|
|
| 598 |
+
| `GET` | `/api/reports/{job_id}/similarity` | `200 SimilarityReport` · `202` not ready · `404` no job | Lives in `reports.py` beside `/json`, `/pdf`, `/graph`. |
|
| 599 |
+
| `POST` | `/api/similarity/{job_id}/recheck` | `202 {job_id, status: "rechecking"}` | Re-runs against the current corpus using the stored normalized text — **no PDF re-parse, no re-analysis**. Runs as a `BackgroundTask`, same as `POST /analyze`. |
|
| 600 |
+
| `GET` | `/api/similarity/status` | `200 SimilarityStatus` | Which providers are configured, corpus size, thresholds. Directly modelled on `GET /text-detection/status`, and for the same reason: it is how an operator distinguishes "nothing configured" from "configured but failing". |
|
| 601 |
+
|
| 602 |
+
`recheck` needs a guard: reject with `409` if a recheck for that job is already in flight,
|
| 603 |
+
tracked with a simple in-process set keyed by `job_id`. Without it, a user clicking the
|
| 604 |
+
button four times launches four concurrent 24-query provider storms and earns a rate-limit
|
| 605 |
+
ban for everyone.
|
| 606 |
+
|
| 607 |
+
Auth: the existing routers are unauthenticated and jobs are filtered by `user_email` as a
|
| 608 |
+
query parameter. Match the existing pattern — do not invent a new auth story inside this
|
| 609 |
+
feature. (Flagged in §18 as a pre-existing gap worth its own work.)
|
| 610 |
+
|
| 611 |
+
---
|
| 612 |
+
|
| 613 |
+
## 10. Frontend
|
| 614 |
+
|
| 615 |
+
### 10.1 Prerequisite refactor
|
| 616 |
+
|
| 617 |
+
`ReportPage.jsx` is **869 lines — already over the 800-line cap** in the coding-style
|
| 618 |
+
rules (826 before the merge; the AI-badge work added to it). Before adding anything:
|
| 619 |
+
|
| 620 |
+
- Move `buildReport()` and its helpers → `pages/reportAdapter.js` (~180 lines).
|
| 621 |
+
- Move the `DEMO` constant → `pages/reportDemo.js` (~90 lines).
|
| 622 |
+
|
| 623 |
+
That brings `ReportPage.jsx` to roughly 590 lines and makes room. This is exactly the
|
| 624 |
+
"targeted improvement to code you are working in" case — it is required to add the
|
| 625 |
+
feature cleanly, not opportunistic refactoring.
|
| 626 |
+
|
| 627 |
+
### 10.2 New components
|
| 628 |
+
|
| 629 |
+
**`SimilarityIndex.jsx`** — the header block from the screenshot: one large percentage
|
| 630 |
+
plus three bucket figures. Follows `DESIGN.md` / `PRODUCT.md` brand rules: obsidian
|
| 631 |
+
background, glass card, Instrument Serif for the big number, DM Mono for the bucket
|
| 632 |
+
figures. **Restraint over alarm** — the number is set in the standard white display
|
| 633 |
+
treatment, not red. Red is reserved for errors in this brand, and colouring a similarity
|
| 634 |
+
score red makes an accusation the data does not support. A single accent colour appears
|
| 635 |
+
only on the per-source chips, to tie a source to its highlight colour on the page.
|
| 636 |
+
|
| 637 |
+
**`SimilaritySources.jsx`** — the numbered list. Each row: colour chip with index, title
|
| 638 |
+
as a link (internal-corpus rows are deliberately not links), provider label, percentage.
|
| 639 |
+
Clicking a row scrolls the page viewer to that source's first highlight and dims the
|
| 640 |
+
others.
|
| 641 |
+
|
| 642 |
+
**`CoverageNote.jsx`** — always rendered, never behind a disclosure triangle:
|
| 643 |
+
|
| 644 |
+
> Checked 6,180 words against 412 candidate sources from CORE, arXiv, OpenAlex, Crossref
|
| 645 |
+
> and 1,204 papers in the CitationEdge corpus. Quoted text and bibliography excluded.
|
| 646 |
+
> Matches under 8 consecutive words are not counted. This is not a Turnitin-equivalent
|
| 647 |
+
> index — coverage is limited to open-access literature and papers analysed here.
|
| 648 |
+
|
| 649 |
+
When `status === "partial"`, prepend: *"One or more sources could not be reached; this
|
| 650 |
+
figure is a lower bound."* When `unavailable`, the component replaces the whole block —
|
| 651 |
+
**no percentage is rendered at all.**
|
| 652 |
+
|
| 653 |
+
### 10.3 Wiring
|
| 654 |
+
|
| 655 |
+
`ReportPage.jsx` gains ~8 lines: read `result.similarity`, render the three components,
|
| 656 |
+
pass `kind === 'similarity'` highlights to the existing page viewer. `api.js` gains
|
| 657 |
+
`getSimilarity(jobId)` and `recheckSimilarity(jobId)` following the existing helper style.
|
| 658 |
+
|
| 659 |
+
---
|
| 660 |
+
|
| 661 |
+
## 11. PDF report
|
| 662 |
+
|
| 663 |
+
`agents/report_agent.py` is **979 lines** after the merge — already past the cap. Nothing
|
| 664 |
+
new goes in inline; the extraction below is now a precondition, not a preference.
|
| 665 |
+
|
| 666 |
+
- Create `agents/report_sections/similarity_section.py` (~140 lines) exporting
|
| 667 |
+
`build_similarity_section(report: dict) -> list[Flowable]` returning ReportLab
|
| 668 |
+
flowables.
|
| 669 |
+
- `report_agent.py` imports it and appends the result — a two-line change. Consider
|
| 670 |
+
extracting the existing AI-badge section the same way while you are in there; at 979
|
| 671 |
+
lines the file is the worst offender in the repo.
|
| 672 |
+
- Section contents: the index block, the primary-sources table, and the coverage note
|
| 673 |
+
**verbatim from the UI**. The disclosure must survive the transition to PDF, because
|
| 674 |
+
the PDF is the artefact that gets emailed, forwarded, and attached to decisions.
|
| 675 |
+
- Ordering: place it after AI-text detection and before citation gaps — the two
|
| 676 |
+
provenance signals belong together.
|
| 677 |
+
|
| 678 |
+
`report` is wave 5 and `similarity` is wave 3, so the data is guaranteed present. When
|
| 679 |
+
similarity is `unavailable`, the section renders a one-line "not available for this
|
| 680 |
+
document" note rather than being omitted, so a reader can tell the check ran.
|
| 681 |
+
|
| 682 |
+
---
|
| 683 |
+
|
| 684 |
+
## 12. Configuration
|
| 685 |
+
|
| 686 |
+
`similarity/config.py`, pydantic-settings, mirroring `text_detection/config.py`.
|
| 687 |
+
|
| 688 |
+
| Env var | Default | Purpose |
|
| 689 |
+
|---|---|---|
|
| 690 |
+
| `SIM_ENABLED` | `true` | Master switch. |
|
| 691 |
+
| `SIM_CORE_API_KEY` | `""` | CORE key. Empty ⇒ unauthenticated (heavily rate-limited) — logged as a warning at startup. |
|
| 692 |
+
| `SIM_ENABLE_CORE` | `true` | Per-provider toggles… |
|
| 693 |
+
| `SIM_ENABLE_ARXIV` | `true` | |
|
| 694 |
+
| `SIM_ENABLE_OPENALEX` | `true` | |
|
| 695 |
+
| `SIM_ENABLE_CROSSREF` | `true` | |
|
| 696 |
+
| `SIM_ENABLE_INTERNAL` | `true` | |
|
| 697 |
+
| `SIM_INDEX_UPLOADS` | `false` | **Ships off.** Indexing users' papers into a shared corpus is a consent decision, not a default. See §16 risk 6. |
|
| 698 |
+
| `SIM_KGRAM_SIZE` | `5` | Winnowing k, in words. |
|
| 699 |
+
| `SIM_WINDOW_SIZE` | `4` | Winnowing w. Guarantees detection of any shared run ≥ `w + k − 1` = 8 words. |
|
| 700 |
+
| `SIM_MIN_MATCH_WORDS` | `8` | Minimum reportable match. |
|
| 701 |
+
| `SIM_MIN_DOCUMENT_WORDS` | `300` | Below this ⇒ `unavailable`. |
|
| 702 |
+
| `SIM_MAX_QUERIES` | `24` | Phrase queries per document. |
|
| 703 |
+
| `SIM_MAX_CANDIDATES` | `400` | Candidates verified per document. |
|
| 704 |
+
| `SIM_MAX_PDF_FETCHES` | `5` | OA PDFs downloaded per document. |
|
| 705 |
+
| `SIM_BUDGET_SECONDS` | `45` | Hard wall-clock cap on retrieval. |
|
| 706 |
+
| `SIM_EXCLUDE_QUOTES` | `true` | |
|
| 707 |
+
| `SIM_EXCLUDE_BIBLIOGRAPHY` | `true` | |
|
| 708 |
+
| `SIM_PARAPHRASE_THRESHOLD` | `0.92` | SciBERT cosine for the paraphrase flag. |
|
| 709 |
+
| `SIM_ENABLE_PARAPHRASE` | `true` | |
|
| 710 |
+
|
| 711 |
+
Every threshold that affects a number is echoed into `Coverage` so any report is
|
| 712 |
+
reproducible from its own output.
|
| 713 |
+
|
| 714 |
+
---
|
| 715 |
+
|
| 716 |
+
## 13. Testing strategy
|
| 717 |
+
|
| 718 |
+
TDD per the project rules: test first (RED), minimal implementation (GREEN), refactor,
|
| 719 |
+
80% minimum coverage. The architecture is built around making this possible — the entire
|
| 720 |
+
scoring core is pure functions over strings and lists, with zero I/O.
|
| 721 |
+
|
| 722 |
+
### Unit — `tests/similarity/`
|
| 723 |
+
|
| 724 |
+
| File | What it pins down |
|
| 725 |
+
|---|---|
|
| 726 |
+
| `test_normalize.py` | Offset map round-trips: for every word in the normalized text, the recorded char offsets slice the *original* text back to the same word. This is what makes highlighting land on the right pixels. |
|
| 727 |
+
| `test_fingerprint.py` | **Property test:** for random text pairs sharing a run of ≥ `w+k−1` words, the fingerprint sets always intersect. This is the winnowing correctness guarantee and it is the foundation of every recall claim in this document. |
|
| 728 |
+
| `test_exclusions.py` | Bibliography, quotes, and title block are removed from the denominator; a paper that is 100% bibliography yields `unavailable`, not 100%. |
|
| 729 |
+
| `test_selector.py` | Phrases are stratified across sections; boilerplate and all-stopword phrases are never selected. |
|
| 730 |
+
| `test_matcher.py` | Seed → span extension produces exact word and char boundaries; overlapping seeds merge into one span, not two. |
|
| 731 |
+
| `test_aggregate.py` | The scoring spec in §4, case by case: identical text ⇒ 100%; disjoint text ⇒ 0%; **one span claimed by three sources counts once** (the double-count regression); attribution goes to the longest match; bucket percentages are independent and may exceed the headline; matches below `min_match_words` are dropped. |
|
| 732 |
+
| `test_semantic.py` | Paraphrase flags never alter `overall_percent` — asserted directly, because this is the invariant most likely to be broken by a future "improvement". |
|
| 733 |
+
| `test_privacy.py` | An internal-corpus `SourceMatch` never carries the other document's title, URL, authors, or text. Asserted at the boundary so a UI change cannot leak it. |
|
| 734 |
+
|
| 735 |
+
### Provider tests — no network
|
| 736 |
+
|
| 737 |
+
`httpx.MockTransport` with recorded fixtures for CORE / arXiv / OpenAlex / Crossref,
|
| 738 |
+
covering: happy path, 429, 5xx, malformed JSON, empty results, and a candidate with no
|
| 739 |
+
resolvable URL. Matches the existing "no real DB or LLM required" testing posture in
|
| 740 |
+
`backend/tests/conftest.py`.
|
| 741 |
+
|
| 742 |
+
### Integration — `backend/tests/test_similarity.py`
|
| 743 |
+
|
| 744 |
+
Router behaviour with a mocked pipeline, via `app.dependency_overrides`: report retrieval,
|
| 745 |
+
202 while pending, 404 unknown job, recheck accepted, recheck 409 when already in flight,
|
| 746 |
+
status endpoint shape.
|
| 747 |
+
|
| 748 |
+
### Pipeline test
|
| 749 |
+
|
| 750 |
+
One end-to-end test with a stub provider returning a known document: assert the pipeline
|
| 751 |
+
produces exactly the expected percentage. This is the test that catches integration drift
|
| 752 |
+
between the seven modules.
|
| 753 |
+
|
| 754 |
+
---
|
| 755 |
+
|
| 756 |
+
## 14. Evaluation and calibration
|
| 757 |
+
|
| 758 |
+
A similarity number nobody has measured is a guess with a decimal point.
|
| 759 |
+
`scripts/eval_similarity.py`, modelled on the existing `scripts/eval_text_detection.py`:
|
| 760 |
+
|
| 761 |
+
**Construction.** Take N open-access papers with known full text from CORE. Build
|
| 762 |
+
synthetic documents with *known* copy rates: 0%, 5%, 10%, 25%, 50% verbatim insertion from
|
| 763 |
+
other OA papers, at varying run lengths (10 / 50 / 200 words). Also build a paraphrased
|
| 764 |
+
set (LLM-reworded insertions) to measure how much verbatim matching misses by design.
|
| 765 |
+
|
| 766 |
+
**Metrics.**
|
| 767 |
+
|
| 768 |
+
| Metric | Target | Why |
|
| 769 |
+
|---|---|---|
|
| 770 |
+
| Mean absolute error vs. true copy rate | ≤ 3 points at 200-word runs | The headline claim. |
|
| 771 |
+
| False-positive rate on the 0% set | **< 1%** | The number that matters most. A clean paper reading 6% destroys trust permanently. |
|
| 772 |
+
| Recall by run length | reported, not gated | Honest characterisation of the sampling limit from §3. |
|
| 773 |
+
| Paraphrase recall | reported | Quantifies what the verbatim index cannot see, and sizes the paraphrase signal. |
|
| 774 |
+
| p50 / p95 latency | p95 ≤ `SIM_BUDGET_SECONDS` | Confirms the budget is real. |
|
| 775 |
+
|
| 776 |
+
**Calibration.** Tune `SIM_MIN_MATCH_WORDS` and the boilerplate stoplist against the 0%
|
| 777 |
+
set until FPR < 1%, *then* measure MAE. Precision first: an inflated score on an honest
|
| 778 |
+
paper is a much worse failure than a missed match on a dishonest one. Commit the results
|
| 779 |
+
to `similarity_eval_report.md` alongside the existing `agent_accuracy_report.md`.
|
| 780 |
+
|
| 781 |
+
---
|
| 782 |
+
|
| 783 |
+
## 15. Build order — phases with exit criteria
|
| 784 |
+
|
| 785 |
+
Each phase is independently shippable and independently testable. Phases 0-1 deliver a
|
| 786 |
+
working, provable feature with no external dependency at all — which is what makes the
|
| 787 |
+
external-provider work in Phase 2 low-risk.
|
| 788 |
+
|
| 789 |
+
| Phase | Work | Exit criteria | Rough size |
|
| 790 |
+
|---|---|---|---|
|
| 791 |
+
| ✅ **0. Scoring core** | `schema`, `normalize`, `exclusions`, `fingerprint`, `selector`, `matcher`, `aggregate`. Pure functions, no I/O. | All unit tests green incl. the winnowing property test. Feeding a document against itself yields exactly 100%; against unrelated text, 0%. | ~1,000 lines + tests |
|
| 792 |
+
| ✅ **1. Internal corpus** | `corpus/internal.py`, `index_writer.py`, Mongo collections + indexes, privacy boundary, corpus purge on job delete. | Upload paper A, then upload A again ⇒ ~100% against the internal corpus, with the other document's identity **not** in the response. Privacy test green. | ~300 |
|
| 793 |
+
| ✅ **2. External providers** | `corpus/{base,core_api,arxiv,openalex,crossref}.py`. Lift shared search into `services/academic_search.py`. | Mocked-transport tests green. Manual run against a paper with a known arXiv preprint finds it. | ~550 |
|
| 794 |
+
| ✅ **3. Pipeline + agent** | `pipeline.py`, `semantic.py`, `SimilarityAgent`, orchestrator wiring, budget, persistence. | Full analysis job produces `results.similarity`; killing CORE mid-run yields `status="partial"` and a completed job. | ~450 |
|
| 795 |
+
| ✅ **4. API** | `reports.py` extension, `routers/similarity.py`, schemas, recheck guard. | Integration tests green; `/docs` shows the endpoints. | ~200 |
|
| 796 |
+
| ⬜ **5. Highlighting** | Generalize `PdfHighlightMapper`; single combined mapper invocation. | Similarity spans render as boxes on real page images; existing AI-detection highlights unchanged and their tests still pass. | ~120 |
|
| 797 |
+
| ⬜ **6. Frontend** | `ReportPage.jsx` extraction refactor, then the three new components + `api.js`. | Report page renders the block; `ReportPage.jsx` back under 800 lines; `unavailable` renders no percentage. | ~450 |
|
| 798 |
+
| ⬜ **7. PDF section** | `report_sections/similarity_section.py`. | Generated PDF contains the block and the coverage note. | ~150 |
|
| 799 |
+
| ⬜ **8. Eval + docs** | `scripts/eval_similarity.py`, calibration, `backend.md` + `README.md` updates. | FPR < 1% on the 0% set; `similarity_eval_report.md` committed. | ~300 |
|
| 800 |
+
|
| 801 |
+
**Phases 0–4 are done and merged** (commits `12cfcb6`, `69c39cd`, `658a727`, `ecc8207`,
|
| 802 |
+
`328d8c2`, merged at `506ad88`). Post-merge suite: **175 passed, 2 failed, 1 skipped** —
|
| 803 |
+
both failures are `frontend-app/dist` missing in the test environment
|
| 804 |
+
(`test_root`, `test_spa_served_on_client_side_routes`), unrelated to similarity.
|
| 805 |
+
|
| 806 |
+
**Suggested checkpoints:** review after Phase 1 (the scoring is provable and needs no
|
| 807 |
+
network — this is where a wrong percentage formula is cheapest to fix), and again after
|
| 808 |
+
Phase 3 (the first time it touches a real job).
|
| 809 |
+
|
| 810 |
+
---
|
| 811 |
+
|
| 812 |
+
## 16. Risks and mitigations
|
| 813 |
+
|
| 814 |
+
**1. The number is read as a misconduct verdict.**
|
| 815 |
+
*Impact: severe — someone's career.* This is the dominant risk and it is not technical.
|
| 816 |
+
Mitigations: the "Corpus Similarity" label instead of "Similarity Index"; mandatory
|
| 817 |
+
coverage disclosure in UI *and* PDF; no red/alarm styling; no threshold that produces a
|
| 818 |
+
pass/fail; language throughout is "passages to review", never "plagiarism detected".
|
| 819 |
+
`status="unavailable"` renders no number at all.
|
| 820 |
+
|
| 821 |
+
**2. Preprint self-match.** A published paper will match its own arXiv preprint at ~100%
|
| 822 |
+
and produce a terrifying, meaningless score. **This is the single most likely first bug
|
| 823 |
+
report.** Mitigation: after retrieval and before scoring, drop any candidate whose
|
| 824 |
+
normalized title has ≥ 0.9 token overlap with the uploaded paper's title, or whose DOI
|
| 825 |
+
matches, or whose author set overlaps by ≥ 50% while similarity is > 60%. Surface dropped
|
| 826 |
+
candidates in `coverage.notes` as "1 probable preprint/self-citation excluded" so the
|
| 827 |
+
exclusion is visible rather than mysterious. Needs its own test fixture.
|
| 828 |
+
|
| 829 |
+
**3. Boilerplate false positives.** "The rest of this paper is organized as follows",
|
| 830 |
+
standard method descriptions, dataset names, common equations. Mitigation: the
|
| 831 |
+
`SIM_MIN_MATCH_WORDS=8` floor; a curated boilerplate stoplist seeded from the eval corpus
|
| 832 |
+
(any 8-gram appearing in > 0.5% of sampled papers); exclusion of equations and captions.
|
| 833 |
+
Measured directly by the FPR metric in §14.
|
| 834 |
+
|
| 835 |
+
**4. CORE rate limiting.** Unregistered access is ~5 requests / 10s. Mitigations: register
|
| 836 |
+
for a key; shared semaphore + stagger (the `_PROVIDER_SEM` pattern already in the
|
| 837 |
+
codebase); `@async_retry` with backoff; phrase-query result caching keyed by phrase hash;
|
| 838 |
+
the hard time budget; failures recorded in `coverage.providers_failed`, never swallowed.
|
| 839 |
+
|
| 840 |
+
**5. Pipeline latency.** Mitigations: wave 3 parallelism, `critical=False`, the 45s hard
|
| 841 |
+
budget, and partial results. Worst case the wave takes 45s instead of ~40s.
|
| 842 |
+
|
| 843 |
+
**6. Corpus consent.** Indexing a user's paper so it can be matched against other users'
|
| 844 |
+
papers is a meaningful commitment about their unpublished work. Mitigations:
|
| 845 |
+
`SIM_INDEX_UPLOADS` **ships `false`**; enabling it is a deployment decision that requires
|
| 846 |
+
a corresponding change to user-facing terms; fingerprints and normalized text are stored,
|
| 847 |
+
and the privacy boundary in §7.4 means a match never reveals whose paper it hit. A
|
| 848 |
+
deletion path (`DELETE /jobs/{id}` must also purge `corpus_fingerprints` and
|
| 849 |
+
`corpus_texts`) is required before this is switched on — **built in Phase 1**.
|
| 850 |
+
|
| 851 |
+
**7. Scanned PDFs with no text layer.** No text ⇒ no fingerprints ⇒ 0%, which reads as
|
| 852 |
+
"clean" and is actually "we couldn't look". Mitigation: `SIM_MIN_DOCUMENT_WORDS` forces
|
| 853 |
+
`unavailable`. `PdfHighlightMapper` already flags `hasTextLayer=false` per page; surface
|
| 854 |
+
that in `coverage.notes`. `VisualParserAgent` exists but is figure-oriented — OCR fallback
|
| 855 |
+
is out of scope here.
|
| 856 |
+
|
| 857 |
+
**8. Non-English papers.** Normalization is Latin-script-oriented and the corpus skews
|
| 858 |
+
English. `PRODUCT.md` already lists non-English support as undecided. Mitigation: detect
|
| 859 |
+
and note in `coverage.notes`; do not silently return a meaningless low score.
|
| 860 |
+
|
| 861 |
+
---
|
| 862 |
+
|
| 863 |
+
## 17. What we are deliberately NOT building
|
| 864 |
+
|
| 865 |
+
Per YAGNI, and to keep the first version reviewable:
|
| 866 |
+
|
| 867 |
+
- **A web crawler.** The "Internet Sources" bucket is open-access academic literature, not
|
| 868 |
+
the web. Anything else is a dishonest label.
|
| 869 |
+
- **A commercial API fallback.** Explicitly rejected in §2. The `CorpusProvider` protocol
|
| 870 |
+
means one could be added later as one more provider — but only as a deliberate decision,
|
| 871 |
+
not a hedge.
|
| 872 |
+
- **Cross-language similarity.** Requires translation of the whole corpus.
|
| 873 |
+
- **Source-code similarity.** Different tokenization, different product.
|
| 874 |
+
- **An instructor/reviewer dashboard, class management, submission windows.** Turnitin is
|
| 875 |
+
a workflow product; this is an analysis feature.
|
| 876 |
+
- **Auto-generated misconduct reports.** We surface evidence. A human decides.
|
| 877 |
+
- **An LLM in the scoring path.** The percentage must be reproducible and explainable. An
|
| 878 |
+
LLM may later *summarize* a report; it must never *compute* one.
|
| 879 |
+
|
| 880 |
+
---
|
| 881 |
+
|
| 882 |
+
## 18. Open questions
|
| 883 |
+
|
| 884 |
+
Not blocking — each has a stated default so implementation can proceed.
|
| 885 |
+
|
| 886 |
+
1. **CORE API key** — needs registering at `core.ac.uk/services/api`. *Default: build and
|
| 887 |
+
test unauthenticated with a low `SIM_MAX_QUERIES`; register before Phase 8 eval, which
|
| 888 |
+
will otherwise take hours.*
|
| 889 |
+
2. **Corpus consent copy** — if `SIM_INDEX_UPLOADS` is ever switched on, the upload page
|
| 890 |
+
needs a line about it. *Default: ships off; revisit when enabling.*
|
| 891 |
+
3. **Recheck rate limiting per user** — the in-process guard prevents concurrent rechecks
|
| 892 |
+
of the same job, not a user rechecking forty jobs. *Default: ship the per-job guard;
|
| 893 |
+
add per-user throttling if it becomes a problem.*
|
| 894 |
+
4. **Auth on the new endpoints** — the existing routers are unauthenticated and filter by
|
| 895 |
+
`user_email` as a query param, which means anyone with a `job_id` can read a report.
|
| 896 |
+
Similarity data is more sensitive than a keyword list. *Default: match the existing
|
| 897 |
+
pattern for consistency; flag it as a pre-existing gap deserving its own piece of work
|
| 898 |
+
rather than a divergent auth story invented inside this feature.*
|
| 899 |
+
|
| 900 |
+
---
|
| 901 |
+
|
| 902 |
+
## Appendix — key source references
|
| 903 |
+
|
| 904 |
+
- Schleimer, Wilkerson & Aiken (2003), *Winnowing: Local Algorithms for Document
|
| 905 |
+
Fingerprinting* — the algorithm behind MOSS, and behind §4's detection guarantee that
|
| 906 |
+
any shared run of ≥ `w + k − 1` tokens is found.
|
| 907 |
+
- [CORE API v3](https://core.ac.uk/services/api) — ~300M metadata records, 40M+ full
|
| 908 |
+
texts, free, `fullText:"..."` phrase query support.
|
| 909 |
+
[Docs](https://api.core.ac.uk/docs/v3).
|
| 910 |
+
- [OpenAlex works search](https://docs.openalex.org/api-entities/works/search-works) —
|
| 911 |
+
already integrated in `services/paper_recommender.py`.
|
| 912 |
+
- Existing code this plan builds on, and must not duplicate:
|
| 913 |
+
`services/paper_recommender.py` (providers, tokenization, ranking),
|
| 914 |
+
`services/pdf_highlight_mapper.py` (PDF coordinate mapping),
|
| 915 |
+
`services/vector_store_service.py` (SciBERT),
|
| 916 |
+
`services/lancedb_service.py` (vector store),
|
| 917 |
+
`text_detection/` (package shape, degradation posture, status endpoint),
|
| 918 |
+
`workflows/hybrid_workflow.py` (wave semantics and critical-agent behaviour).
|
plan_done.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Similarity Report Feature — Work Done (Phases 0–4)
|
| 2 |
+
|
| 3 |
+
This document records everything implemented for the **Corpus Similarity Report**
|
| 4 |
+
feature on branch `report-ui-updated`, per `plan.md` + `opencode-prompt.md`.
|
| 5 |
+
The feature is complete through Phase 4 (API). Phase 5 (PDF highlight mapper
|
| 6 |
+
integration) was **not** started.
|
| 7 |
+
|
| 8 |
+
> **Superseded 2026-08-02:** the remote merge described under "Remaining" is DONE
|
| 9 |
+
> (merge commit `506ad88`). All 9 conflicts resolved; suite is 175 passed / 2 failed
|
| 10 |
+
> (both `frontend-app/dist` env failures). See the post-merge box at the top of
|
| 11 |
+
> `plan.md` for what the merge changed. Resume at Phase 5.
|
| 12 |
+
|
| 13 |
+
All 5 commits are local-only (based on `33cb4a2`) and have **not** been pushed.
|
| 14 |
+
The remote `report-ui-updated` branch has diverged (20+ commits of Groq-key UI,
|
| 15 |
+
citation-relevance/novelty agents, frontend work, LF line endings) and contains
|
| 16 |
+
**zero** similarity code.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Commits
|
| 21 |
+
|
| 22 |
+
| Commit | Description |
|
| 23 |
+
|---|---|
|
| 24 |
+
| `12cfcb6` | `feat(similarity): Phase 0 scoring core — normalize, exclusions, winnowing, matcher, aggregate` |
|
| 25 |
+
| `69c39cd` | `feat(similarity): Phase 1 internal corpus — write path, read path, privacy` |
|
| 26 |
+
| `658a727` | `feat(similarity): Phase 2 external providers — CORE, arXiv, OpenAlex, Crossref` |
|
| 27 |
+
| `ecc8207` | `feat(similarity): Phase 3 pipeline + agent` |
|
| 28 |
+
| `328d8c2` | `feat(similarity): Phase 4 API — report, recheck, status` |
|
| 29 |
+
|
| 30 |
+
Working tree currently contains 212 files of pure CRLF-vs-LF noise
|
| 31 |
+
(`git diff -w` collapses them to 0) plus one dirty LFS binary
|
| 32 |
+
`models/kbir_semeval2017/training_args.bin`. Plan agreed with user:
|
| 33 |
+
normalize line endings to LF in a housekeeping commit, then merge the remote.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Files added
|
| 38 |
+
|
| 39 |
+
### Similarity package (`similarity/`)
|
| 40 |
+
- `__init__.py` — package export
|
| 41 |
+
- `config.py` — settings + environment toggles
|
| 42 |
+
- `schema.py` — `SimilarityReport`, `SourceMatch`, `Coverage`, providers
|
| 43 |
+
- `normalize.py` — text normalization
|
| 44 |
+
- `exclusions.py` — stopword/boilerplate exclusion
|
| 45 |
+
- `fingerprint.py` — winnowing fingerprints (k=5, w=4)
|
| 46 |
+
- `matcher.py` — matching + attribution
|
| 47 |
+
- `aggregate.py` — aggregation into report
|
| 48 |
+
- `index_writer.py` — corpus fingerprint write path
|
| 49 |
+
- `selector.py` — internal-corpus read path
|
| 50 |
+
- `pipeline.py` — `SimilarityPipeline` orchestration
|
| 51 |
+
- `semantic.py` — semantic paraphrase pass
|
| 52 |
+
- `corpus/base.py` — provider protocol
|
| 53 |
+
- `corpus/internal.py` — internal corpus provider
|
| 54 |
+
- `corpus/core_api.py` — CORE provider
|
| 55 |
+
- `corpus/arxiv.py` — arXiv provider (https base)
|
| 56 |
+
- `corpus/openalex.py` — OpenAlex provider
|
| 57 |
+
- `corpus/crossref.py` — Crossref provider
|
| 58 |
+
|
| 59 |
+
### Agent (`agents/similarity_agent.py`)
|
| 60 |
+
`SimilarityAgent` (`name="similarity"`, `wave=3`, `critical=False`) — reads
|
| 61 |
+
paragraph/section text from Neo4j, builds providers per `SIM_ENABLE_*` toggles,
|
| 62 |
+
persists `SimilaritySource` nodes via `ctx.neo4j.run_write`.
|
| 63 |
+
|
| 64 |
+
### Phase 4 API
|
| 65 |
+
- `backend/routers/similarity.py` — `POST /similarity/{job_id}/recheck` (202,
|
| 66 |
+
409 in-flight guard, `BackgroundTasks`), `GET /similarity/status`
|
| 67 |
+
- `backend/schemas/similarity.py` — `SimilarityStatus`, `RecheckAccepted`
|
| 68 |
+
- `backend/tests/test_similarity.py` — 7 tests (report 200/202/404, recheck
|
| 69 |
+
accepted/409/404, status shape)
|
| 70 |
+
|
| 71 |
+
## Files modified
|
| 72 |
+
- `backend/routers/reports.py` — added `GET /reports/{job_id}/similarity`
|
| 73 |
+
(200 report / 202 not-ready / 404 unknown), `response_model=SimilarityReport`
|
| 74 |
+
- `backend/main.py` — router registration
|
| 75 |
+
- `orchestrators/custom_orchestrator.py` — `get_agents()` + similarity read-off,
|
| 76 |
+
`upsert_job`, `save_result` (3 spots)
|
| 77 |
+
- `agents/__init__.py` — export
|
| 78 |
+
- `configs/pipeline_config.yaml` — `similarity: wave 3, depends_on: [parser, keyword]`
|
| 79 |
+
- `tests/test_agents.py` — +1 SimilarityAgent short-text/`unavailable` test
|
| 80 |
+
- New tests: `tests/similarity/test_pipeline.py` (8), `test_self_match_guard.py`
|
| 81 |
+
(9), `test_semantic.py` (3)
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Key algorithm facts (stable)
|
| 86 |
+
- Winnowing k=5, w=4; coverage bitmap `covered[i] = source_index|None`.
|
| 87 |
+
- Attribution longest-match, ties by trust (internal > CORE > publisher).
|
| 88 |
+
- Bucket percents computed independently; status `complete|partial|unavailable`
|
| 89 |
+
(< `SIM_MIN_DOCUMENT_WORDS`=300 → unavailable).
|
| 90 |
+
- Headline is verbatim-only; semantic pass never touches `overall_percent`.
|
| 91 |
+
- Preprint self-match guard: title token-overlap ≥ 0.9, DOI match, author overlap
|
| 92 |
+
≥ 50% at similarity > 60%.
|
| 93 |
+
- Budget timeout → `status="partial"` + `coverage.budget_exhausted=True`.
|
| 94 |
+
- Provider exceptions caught → `last_error` + `coverage.providers_failed`.
|
| 95 |
+
- Privacy boundary (plan §7.4): internal matches via `build_source_match()`
|
| 96 |
+
(neutral label, no URL/authors, stripped `source_excerpt`).
|
| 97 |
+
- Recheck reuses stored Neo4j text (no PDF re-parse); `save_result` uses `$set`
|
| 98 |
+
merge so refresh is non-breaking.
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## Test status (last full run)
|
| 103 |
+
`tests/ backend/tests/ tests/similarity/` → **212 passed, 7 failed**. All 7
|
| 104 |
+
failures are pre-existing and environment-related (not caused by the similarity
|
| 105 |
+
work) — verified they also fail at Phase 3 commit `ecc8207`:
|
| 106 |
+
|
| 107 |
+
- `tests/test_agents.py::test_scoring_agent_returns_result`
|
| 108 |
+
- `tests/test_text_pipeline.py::test_no_detectors_enabled_returns_uncertain`
|
| 109 |
+
- `backend/tests/test_health.py::test_root` (404 — no `frontend-app/dist`)
|
| 110 |
+
- `backend/tests/test_reports.py::test_download_pdf` (500)
|
| 111 |
+
- `backend/tests/test_text_detection.py` (3 tests — model returns REAL not
|
| 112 |
+
AI_GENERATED)
|
| 113 |
+
|
| 114 |
+
`tests/similarity/` alone: 112/112 green. `backend/tests/test_similarity.py`: 7/7.
|
| 115 |
+
|
| 116 |
+
Test command:
|
| 117 |
+
```
|
| 118 |
+
cd /mnt/c/citation_edge/IIT-Patna && timeout N /tmp/opencode/venv/bin/python -m pytest tests backend/tests tests/similarity -q
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## Environment notes
|
| 124 |
+
- venv `/tmp/opencode/venv` (Py 3.12); missing torch/transformers installed
|
| 125 |
+
(CPU torch). Also installed python-dotenv, aiofiles, pdfplumber, reportlab,
|
| 126 |
+
pyyaml, tenacity, structlog, motor, pymongo, uvicorn, pymupdf, matplotlib,
|
| 127 |
+
scikit-learn, keybert, scipy, fastapi/alice httpx, pydantic 2.13.4.
|
| 128 |
+
- Git auth: pull via askpass script `/tmp/opencode/git-askpass.sh`
|
| 129 |
+
(Username `x-access-token`), token supplied by user; never written to repo.
|
| 130 |
+
- git user.name/user.email (per-repo): `Vansh Patil` / `vanshpatil@Vansh.localdomain`.
|
| 131 |
+
- Commits carry a "Git LFS" hook warning (noise only).
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Remaining (Phase 5+)
|
| 136 |
+
- Phase 5: PDF highlight mapper integration — combined AI+similarity span list,
|
| 137 |
+
`kind` field on spans, single mapper call (plan §8).
|
| 138 |
+
- ~~Merge remote `report-ui-updated`~~ **DONE** (`506ad88`) — resolved 9 overlapping files
|
| 139 |
+
(`backend/main.py`, `backend/routers/reports.py`,
|
| 140 |
+
`orchestrators/custom_orchestrator.py`, `configs/pipeline_config.yaml`,
|
| 141 |
+
`agents/__init__.py`, `backend/routers/jobs.py`, `backend/tests/conftest.py`,
|
| 142 |
+
`backend/tests/test_jobs.py`, `tests/test_agents.py`).
|
| 143 |
+
- ~~Push local commits~~ **DONE**.
|
services/academic_search.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared academic-search providers (OpenAlex, Crossref, arXiv).
|
| 2 |
+
|
| 3 |
+
Lifted out of `services/paper_recommender.py` so both the citation-gap
|
| 4 |
+
recommender and the similarity pipeline's corpus providers can import the
|
| 5 |
+
same functions instead of copying them (plan.md §7.3 DRY note). `paper_recommender`
|
| 6 |
+
re-imports from here, so its public surface is unchanged.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import re
|
| 11 |
+
from typing import Dict, List
|
| 12 |
+
from xml.etree import ElementTree
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
from utils.logger import get_logger
|
| 17 |
+
from utils.retry import async_retry
|
| 18 |
+
|
| 19 |
+
logger = get_logger("academic_search")
|
| 20 |
+
|
| 21 |
+
OPENALEX_BASE = "https://api.openalex.org/works"
|
| 22 |
+
CROSSREF_BASE = "https://api.crossref.org/works"
|
| 23 |
+
ARXIV_BASE = "https://export.arxiv.org/api/query"
|
| 24 |
+
MAILTO = "citationedge@app.local"
|
| 25 |
+
|
| 26 |
+
ARXIV_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
| 27 |
+
|
| 28 |
+
# DOI prefix → publisher (used to label where each paper comes from).
|
| 29 |
+
DOI_PUBLISHER = {
|
| 30 |
+
"10.1016": "Elsevier",
|
| 31 |
+
"10.1007": "Springer",
|
| 32 |
+
"10.1002": "Wiley",
|
| 33 |
+
"10.1111": "Wiley",
|
| 34 |
+
"10.1093": "Oxford",
|
| 35 |
+
"10.1109": "IEEE",
|
| 36 |
+
"10.1038": "Nature",
|
| 37 |
+
"10.1021": "ACS",
|
| 38 |
+
"10.1126": "AAAS",
|
| 39 |
+
"10.1056": "NEJM",
|
| 40 |
+
"10.3390": "MDPI",
|
| 41 |
+
"10.1145": "ACM",
|
| 42 |
+
"10.1080": "Taylor & Francis",
|
| 43 |
+
"10.1175": "AMS",
|
| 44 |
+
"10.1088": "IOP",
|
| 45 |
+
"10.1063": "AIP",
|
| 46 |
+
"10.1017": "Cambridge",
|
| 47 |
+
"10.2139": "SSRN",
|
| 48 |
+
"10.3386": "NBER",
|
| 49 |
+
"10.48550": "arXiv",
|
| 50 |
+
"10.2469": "CFA Institute",
|
| 51 |
+
"10.3905": "Institutional Investor",
|
| 52 |
+
"10.2172": "OSTI",
|
| 53 |
+
"10.5281": "Zenodo",
|
| 54 |
+
"10.1257": "AEA",
|
| 55 |
+
"10.1371": "PLOS",
|
| 56 |
+
"10.18637": "JSS",
|
| 57 |
+
"10.1086": "University of Chicago Press",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# Shared throttle across all searches: providers are burst-sensitive
|
| 61 |
+
# (OpenAlex in particular 429s on parallel bursts).
|
| 62 |
+
PROVIDER_SEM = asyncio.Semaphore(2)
|
| 63 |
+
|
| 64 |
+
_TAG_RE = re.compile(r"<[^>]+>")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def clean_title(title: str) -> str:
|
| 68 |
+
"""Strip HTML tags and unescape entities (OpenAlex titles carry <i>)."""
|
| 69 |
+
import html as _html
|
| 70 |
+
return _html.unescape(_TAG_RE.sub("", title or "")).strip()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _reconstruct_openalex_abstract(inverted: Dict | None) -> str:
|
| 74 |
+
if not inverted:
|
| 75 |
+
return ""
|
| 76 |
+
words: List[tuple[int, str]] = []
|
| 77 |
+
for word, positions in inverted.items():
|
| 78 |
+
words.extend((pos, word) for pos in positions)
|
| 79 |
+
words.sort()
|
| 80 |
+
return " ".join(w for _, w in words)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def source_for(paper: Dict) -> str:
|
| 84 |
+
"""Where is this paper published? DOI prefix → publisher → provider."""
|
| 85 |
+
doi = (paper.get("doi") or "").lower()
|
| 86 |
+
if doi.startswith("10."):
|
| 87 |
+
prefix = ".".join(doi.split(".")[:2])
|
| 88 |
+
return DOI_PUBLISHER.get(prefix, "Crossref")
|
| 89 |
+
url = (paper.get("url") or "").lower()
|
| 90 |
+
if "arxiv.org" in url:
|
| 91 |
+
return "arXiv"
|
| 92 |
+
if "europepmc.org" in url:
|
| 93 |
+
return "Europe PMC"
|
| 94 |
+
if "ncbi.nlm.nih.gov" in url:
|
| 95 |
+
return "PubMed"
|
| 96 |
+
if "openalex.org" in url:
|
| 97 |
+
return "OpenAlex"
|
| 98 |
+
if "crossref.org" in url or "doi.org" in url:
|
| 99 |
+
return "Crossref"
|
| 100 |
+
return "Web"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@async_retry(max_attempts=3, delay=1.5)
|
| 104 |
+
async def _search_openalex(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 105 |
+
resp = await client.get(
|
| 106 |
+
OPENALEX_BASE,
|
| 107 |
+
params={"search": query, "per-page": limit, "mailto": MAILTO},
|
| 108 |
+
timeout=20,
|
| 109 |
+
)
|
| 110 |
+
resp.raise_for_status()
|
| 111 |
+
data = resp.json()
|
| 112 |
+
results = []
|
| 113 |
+
for w in data.get("results", []):
|
| 114 |
+
display = (w.get("display_name") or "").strip()
|
| 115 |
+
if not display:
|
| 116 |
+
continue
|
| 117 |
+
doi = (w.get("doi") or "").replace("https://doi.org/", "")
|
| 118 |
+
results.append({
|
| 119 |
+
"title": display,
|
| 120 |
+
"authors": [
|
| 121 |
+
a.get("author", {}).get("display_name", "")
|
| 122 |
+
for a in w.get("authorships", [])[:5]
|
| 123 |
+
],
|
| 124 |
+
"year": w.get("publication_year"),
|
| 125 |
+
"abstract": _reconstruct_openalex_abstract(w.get("abstract_inverted_index")),
|
| 126 |
+
"citation_count": w.get("cited_by_count", 0),
|
| 127 |
+
"doi": doi,
|
| 128 |
+
"paper_id": w.get("id", ""),
|
| 129 |
+
"url": w.get("id", ""), # landing page: https://openalex.org/W...
|
| 130 |
+
})
|
| 131 |
+
return results
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
CROSSREF_TYPES = {
|
| 135 |
+
"journal-article", "proceedings-article", "posted-content",
|
| 136 |
+
"preprint", "report", "dissertation",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@async_retry(max_attempts=3, delay=1.5)
|
| 141 |
+
async def _search_crossref(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 142 |
+
resp = await client.get(
|
| 143 |
+
CROSSREF_BASE,
|
| 144 |
+
params={"query": query, "rows": limit, "select": "title,author,issued,DOI,is-referenced-by-count,link,URL,type,abstract"},
|
| 145 |
+
timeout=20,
|
| 146 |
+
)
|
| 147 |
+
resp.raise_for_status()
|
| 148 |
+
data = resp.json()
|
| 149 |
+
results = []
|
| 150 |
+
for item in data.get("message", {}).get("items", []):
|
| 151 |
+
if item.get("type") not in CROSSREF_TYPES:
|
| 152 |
+
continue
|
| 153 |
+
title = (item.get("title") or [""])[0].strip()
|
| 154 |
+
if not title:
|
| 155 |
+
continue
|
| 156 |
+
doi = item.get("DOI", "")
|
| 157 |
+
issued = (item.get("issued", {}).get("date-parts") or [[None]])[0][0]
|
| 158 |
+
link = item.get("URL") or item.get("link", [{}])[0].get("URL", "")
|
| 159 |
+
if not link and doi:
|
| 160 |
+
link = f"https://doi.org/{doi}"
|
| 161 |
+
abstract = (item.get("abstract") or "")
|
| 162 |
+
abstract = clean_title(abstract)[:500]
|
| 163 |
+
results.append({
|
| 164 |
+
"title": title,
|
| 165 |
+
"authors": [a.get("given", "") + " " + a.get("family", "") for a in item.get("author", [])[:5]],
|
| 166 |
+
"year": issued,
|
| 167 |
+
"abstract": abstract,
|
| 168 |
+
"citation_count": item.get("is-referenced-by-count", 0),
|
| 169 |
+
"doi": doi,
|
| 170 |
+
"paper_id": doi,
|
| 171 |
+
"url": link,
|
| 172 |
+
})
|
| 173 |
+
return results
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@async_retry(max_attempts=3, delay=1.5)
|
| 177 |
+
async def _search_arxiv(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 178 |
+
"""arXiv API — direct arxiv.org/abs/... links (Atom XML)."""
|
| 179 |
+
resp = await client.get(
|
| 180 |
+
ARXIV_BASE,
|
| 181 |
+
params={"search_query": f"all:{query}", "max_results": limit, "sortBy": "relevance"},
|
| 182 |
+
timeout=20,
|
| 183 |
+
)
|
| 184 |
+
resp.raise_for_status()
|
| 185 |
+
root = ElementTree.fromstring(resp.text)
|
| 186 |
+
results = []
|
| 187 |
+
for entry in root.findall("atom:entry", ARXIV_NS):
|
| 188 |
+
title = clean_title(entry.findtext("atom:title", "", ARXIV_NS))
|
| 189 |
+
if not title:
|
| 190 |
+
continue
|
| 191 |
+
arxiv_id = entry.findtext("atom:id", "", ARXIV_NS).strip()
|
| 192 |
+
if not arxiv_id:
|
| 193 |
+
continue
|
| 194 |
+
doi = next(
|
| 195 |
+
(el.text or "").strip() for el in entry.iter()
|
| 196 |
+
if el.tag.rsplit("}", 1)[-1] == "doi" and el.text
|
| 197 |
+
) if any(el.tag.rsplit("}", 1)[-1] == "doi" and el.text for el in entry.iter()) else ""
|
| 198 |
+
year = None
|
| 199 |
+
published = entry.findtext("atom:published", "", ARXIV_NS)
|
| 200 |
+
if published:
|
| 201 |
+
year = int(published[:4])
|
| 202 |
+
authors = [
|
| 203 |
+
a.findtext("atom:name", "", ARXIV_NS).strip()
|
| 204 |
+
for a in entry.findall("atom:author", ARXIV_NS)[:5]
|
| 205 |
+
]
|
| 206 |
+
summary = " ".join(entry.findtext("atom:summary", "", ARXIV_NS).split())
|
| 207 |
+
results.append({
|
| 208 |
+
"title": title,
|
| 209 |
+
"authors": [a for a in authors if a],
|
| 210 |
+
"year": year,
|
| 211 |
+
"abstract": summary,
|
| 212 |
+
"citation_count": 0, # arXiv has no citation count
|
| 213 |
+
"doi": doi,
|
| 214 |
+
"paper_id": arxiv_id,
|
| 215 |
+
"url": arxiv_id, # direct link: https://arxiv.org/abs/XXXX
|
| 216 |
+
})
|
| 217 |
+
return results
|
services/paper_recommender.py
CHANGED
|
@@ -11,62 +11,35 @@ Providers:
|
|
| 11 |
1. OpenAlex — broad aggregator (publishers, repositories)
|
| 12 |
2. Crossref — publisher metadata (Elsevier, Springer, IEEE, Wiley, ...)
|
| 13 |
3. arXiv — open-access preprint repository (arxiv.org direct links)
|
| 14 |
-
4. Europe PMC —
|
| 15 |
-
5. PubMed —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
import asyncio
|
| 18 |
import math
|
| 19 |
import re
|
| 20 |
from typing import Dict, List, Optional, Tuple
|
| 21 |
-
from xml.etree import ElementTree
|
| 22 |
|
| 23 |
import httpx
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from tools.search_tool import EuropePMCTool, PubMedTool, UnpaywallTool
|
| 26 |
from utils.logger import get_logger
|
| 27 |
-
from utils.retry import async_retry
|
| 28 |
|
| 29 |
logger = get_logger("paper_recommender")
|
| 30 |
|
| 31 |
-
OPENALEX_BASE = "https://api.openalex.org/works"
|
| 32 |
-
CROSSREF_BASE = "https://api.crossref.org/works"
|
| 33 |
-
ARXIV_BASE = "http://export.arxiv.org/api/query"
|
| 34 |
-
MAILTO = "citationedge@app.local"
|
| 35 |
-
|
| 36 |
-
ARXIV_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
| 37 |
-
|
| 38 |
-
# DOI prefix → publisher (used to label where each paper comes from).
|
| 39 |
-
DOI_PUBLISHER = {
|
| 40 |
-
"10.1016": "Elsevier",
|
| 41 |
-
"10.1007": "Springer",
|
| 42 |
-
"10.1002": "Wiley",
|
| 43 |
-
"10.1111": "Wiley",
|
| 44 |
-
"10.1093": "Oxford",
|
| 45 |
-
"10.1109": "IEEE",
|
| 46 |
-
"10.1038": "Nature",
|
| 47 |
-
"10.1021": "ACS",
|
| 48 |
-
"10.1126": "AAAS",
|
| 49 |
-
"10.1056": "NEJM",
|
| 50 |
-
"10.3390": "MDPI",
|
| 51 |
-
"10.1145": "ACM",
|
| 52 |
-
"10.1080": "Taylor & Francis",
|
| 53 |
-
"10.1175": "AMS",
|
| 54 |
-
"10.1088": "IOP",
|
| 55 |
-
"10.1063": "AIP",
|
| 56 |
-
"10.1017": "Cambridge",
|
| 57 |
-
"10.2139": "SSRN",
|
| 58 |
-
"10.3386": "NBER",
|
| 59 |
-
"10.48550": "arXiv",
|
| 60 |
-
"10.2469": "CFA Institute",
|
| 61 |
-
"10.3905": "Institutional Investor",
|
| 62 |
-
"10.2172": "OSTI",
|
| 63 |
-
"10.5281": "Zenodo",
|
| 64 |
-
"10.1257": "AEA",
|
| 65 |
-
"10.1371": "PLOS",
|
| 66 |
-
"10.18637": "JSS",
|
| 67 |
-
"10.1086": "University of Chicago Press",
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
STOPWORDS = {
|
| 71 |
"a", "an", "the", "of", "on", "in", "to", "for", "and", "or", "is", "are",
|
| 72 |
"was", "were", "be", "been", "being", "it", "its", "this", "that", "these",
|
|
@@ -116,7 +89,6 @@ FILLER_TOKENS = {
|
|
| 116 |
}
|
| 117 |
|
| 118 |
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 119 |
-
_TAG_RE = re.compile(r"<[^>]+>")
|
| 120 |
|
| 121 |
# Tokens too weak/generic to help search queries ("for example", "even",
|
| 122 |
# "using"...) plus pure-numeric noise like "1m", "2m".
|
|
@@ -129,12 +101,6 @@ WEAK_QUERY_TOKENS = {
|
|
| 129 |
}
|
| 130 |
|
| 131 |
|
| 132 |
-
def clean_title(title: str) -> str:
|
| 133 |
-
"""Strip HTML tags and unescape entities (OpenAlex titles carry <i>)."""
|
| 134 |
-
import html as _html
|
| 135 |
-
return _html.unescape(_TAG_RE.sub("", title or "")).strip()
|
| 136 |
-
|
| 137 |
-
|
| 138 |
def tokenize(text: str) -> List[str]:
|
| 139 |
"""Gap/query tokens: stopwords + gap-phrasing fillers removed, deduped."""
|
| 140 |
if not text:
|
|
@@ -299,10 +265,6 @@ def rank_papers(
|
|
| 299 |
|
| 300 |
# ── Providers ────────────────────────────────────────────────────────────────
|
| 301 |
|
| 302 |
-
# Shared throttle across all searches: providers are burst-sensitive
|
| 303 |
-
# (OpenAlex in particular 429s on parallel bursts).
|
| 304 |
-
_PROVIDER_SEM = asyncio.Semaphore(2)
|
| 305 |
-
|
| 306 |
|
| 307 |
async def _search_all_providers(
|
| 308 |
client: httpx.AsyncClient, query: str, limit: int = 10
|
|
@@ -312,7 +274,7 @@ async def _search_all_providers(
|
|
| 312 |
results: List[Dict] = []
|
| 313 |
epmc = EuropePMCTool()
|
| 314 |
pubmed = PubMedTool()
|
| 315 |
-
async with
|
| 316 |
for fn in (_search_openalex, _search_crossref, _search_arxiv):
|
| 317 |
try:
|
| 318 |
results.extend(await fn(client, query, limit))
|
|
@@ -327,152 +289,6 @@ async def _search_all_providers(
|
|
| 327 |
await asyncio.sleep(0.25)
|
| 328 |
return results
|
| 329 |
|
| 330 |
-
def _reconstruct_openalex_abstract(inverted: Optional[Dict]) -> str:
|
| 331 |
-
if not inverted:
|
| 332 |
-
return ""
|
| 333 |
-
words: List[Tuple[int, str]] = []
|
| 334 |
-
for word, positions in inverted.items():
|
| 335 |
-
words.extend((pos, word) for pos in positions)
|
| 336 |
-
words.sort()
|
| 337 |
-
return " ".join(w for _, w in words)
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
@async_retry(max_attempts=3, delay=1.5)
|
| 341 |
-
async def _search_openalex(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 342 |
-
resp = await client.get(
|
| 343 |
-
OPENALEX_BASE,
|
| 344 |
-
params={"search": query, "per-page": limit, "mailto": MAILTO},
|
| 345 |
-
timeout=20,
|
| 346 |
-
)
|
| 347 |
-
resp.raise_for_status()
|
| 348 |
-
data = resp.json()
|
| 349 |
-
results = []
|
| 350 |
-
for w in data.get("results", []):
|
| 351 |
-
display = (w.get("display_name") or "").strip()
|
| 352 |
-
if not display:
|
| 353 |
-
continue
|
| 354 |
-
doi = (w.get("doi") or "").replace("https://doi.org/", "")
|
| 355 |
-
results.append({
|
| 356 |
-
"title": display,
|
| 357 |
-
"authors": [
|
| 358 |
-
a.get("author", {}).get("display_name", "")
|
| 359 |
-
for a in w.get("authorships", [])[:5]
|
| 360 |
-
],
|
| 361 |
-
"year": w.get("publication_year"),
|
| 362 |
-
"abstract": _reconstruct_openalex_abstract(w.get("abstract_inverted_index")),
|
| 363 |
-
"citation_count": w.get("cited_by_count", 0),
|
| 364 |
-
"doi": doi,
|
| 365 |
-
"paper_id": w.get("id", ""),
|
| 366 |
-
"url": w.get("id", ""), # landing page: https://openalex.org/W...
|
| 367 |
-
})
|
| 368 |
-
return results
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
CROSSREF_TYPES = {
|
| 372 |
-
"journal-article", "proceedings-article", "posted-content",
|
| 373 |
-
"preprint", "report", "dissertation",
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
@async_retry(max_attempts=3, delay=1.5)
|
| 378 |
-
async def _search_crossref(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 379 |
-
resp = await client.get(
|
| 380 |
-
CROSSREF_BASE,
|
| 381 |
-
params={"query": query, "rows": limit, "select": "title,author,issued,DOI,is-referenced-by-count,link,URL,type,abstract"},
|
| 382 |
-
timeout=20,
|
| 383 |
-
)
|
| 384 |
-
resp.raise_for_status()
|
| 385 |
-
data = resp.json()
|
| 386 |
-
results = []
|
| 387 |
-
for item in data.get("message", {}).get("items", []):
|
| 388 |
-
if item.get("type") not in CROSSREF_TYPES:
|
| 389 |
-
continue
|
| 390 |
-
title = (item.get("title") or [""])[0].strip()
|
| 391 |
-
if not title:
|
| 392 |
-
continue
|
| 393 |
-
doi = item.get("DOI", "")
|
| 394 |
-
issued = (item.get("issued", {}).get("date-parts") or [[None]])[0][0]
|
| 395 |
-
link = item.get("URL") or item.get("link", [{}])[0].get("URL", "")
|
| 396 |
-
if not link and doi:
|
| 397 |
-
link = f"https://doi.org/{doi}"
|
| 398 |
-
abstract = (item.get("abstract") or "")
|
| 399 |
-
abstract = clean_title(abstract)[:500]
|
| 400 |
-
results.append({
|
| 401 |
-
"title": title,
|
| 402 |
-
"authors": [a.get("given", "") + " " + a.get("family", "") for a in item.get("author", [])[:5]],
|
| 403 |
-
"year": issued,
|
| 404 |
-
"abstract": abstract,
|
| 405 |
-
"citation_count": item.get("is-referenced-by-count", 0),
|
| 406 |
-
"doi": doi,
|
| 407 |
-
"paper_id": doi,
|
| 408 |
-
"url": link,
|
| 409 |
-
})
|
| 410 |
-
return results
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
def source_for(paper: Dict) -> str:
|
| 414 |
-
"""Where is this paper published? DOI prefix → publisher → provider."""
|
| 415 |
-
doi = (paper.get("doi") or "").lower()
|
| 416 |
-
if doi.startswith("10."):
|
| 417 |
-
prefix = ".".join(doi.split(".")[:2])
|
| 418 |
-
return DOI_PUBLISHER.get(prefix, "Crossref")
|
| 419 |
-
url = (paper.get("url") or "").lower()
|
| 420 |
-
if "arxiv.org" in url:
|
| 421 |
-
return "arXiv"
|
| 422 |
-
if "europepmc.org" in url or "pmc.ncbi.nlm.nih.gov" in url:
|
| 423 |
-
return "Europe PMC"
|
| 424 |
-
if "pubmed.ncbi.nlm.nih.gov" in url:
|
| 425 |
-
return "PubMed"
|
| 426 |
-
if "openalex.org" in url:
|
| 427 |
-
return "OpenAlex"
|
| 428 |
-
if "crossref.org" in url or "doi.org" in url:
|
| 429 |
-
return "Crossref"
|
| 430 |
-
return "Web"
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
@async_retry(max_attempts=3, delay=1.5)
|
| 434 |
-
async def _search_arxiv(client: httpx.AsyncClient, query: str, limit: int = 10) -> List[Dict]:
|
| 435 |
-
"""arXiv API — direct arxiv.org/abs/... links (Atom XML)."""
|
| 436 |
-
resp = await client.get(
|
| 437 |
-
ARXIV_BASE,
|
| 438 |
-
params={"search_query": f"all:{query}", "max_results": limit, "sortBy": "relevance"},
|
| 439 |
-
timeout=20,
|
| 440 |
-
)
|
| 441 |
-
resp.raise_for_status()
|
| 442 |
-
root = ElementTree.fromstring(resp.text)
|
| 443 |
-
results = []
|
| 444 |
-
for entry in root.findall("atom:entry", ARXIV_NS):
|
| 445 |
-
title = clean_title(entry.findtext("atom:title", "", ARXIV_NS))
|
| 446 |
-
if not title:
|
| 447 |
-
continue
|
| 448 |
-
arxiv_id = entry.findtext("atom:id", "", ARXIV_NS).strip()
|
| 449 |
-
if not arxiv_id:
|
| 450 |
-
continue
|
| 451 |
-
doi = next(
|
| 452 |
-
(el.text or "").strip() for el in entry.iter()
|
| 453 |
-
if el.tag.rsplit("}", 1)[-1] == "doi" and el.text
|
| 454 |
-
) if any(el.tag.rsplit("}", 1)[-1] == "doi" and el.text for el in entry.iter()) else ""
|
| 455 |
-
year = None
|
| 456 |
-
published = entry.findtext("atom:published", "", ARXIV_NS)
|
| 457 |
-
if published:
|
| 458 |
-
year = int(published[:4])
|
| 459 |
-
authors = [
|
| 460 |
-
a.findtext("atom:name", "", ARXIV_NS).strip()
|
| 461 |
-
for a in entry.findall("atom:author", ARXIV_NS)[:5]
|
| 462 |
-
]
|
| 463 |
-
summary = " ".join(entry.findtext("atom:summary", "", ARXIV_NS).split())
|
| 464 |
-
results.append({
|
| 465 |
-
"title": title,
|
| 466 |
-
"authors": [a for a in authors if a],
|
| 467 |
-
"year": year,
|
| 468 |
-
"abstract": summary,
|
| 469 |
-
"citation_count": 0, # arXiv has no citation count
|
| 470 |
-
"doi": doi,
|
| 471 |
-
"paper_id": arxiv_id,
|
| 472 |
-
"url": arxiv_id, # direct link: https://arxiv.org/abs/XXXX
|
| 473 |
-
})
|
| 474 |
-
return results
|
| 475 |
-
|
| 476 |
|
| 477 |
class PaperRecommender:
|
| 478 |
"""Web-based paper recommender (OpenAlex + Crossref + arXiv + Europe PMC
|
|
@@ -555,16 +371,15 @@ class PaperRecommender:
|
|
| 555 |
chosen = {r["title"].lower() for r in recs}
|
| 556 |
recs += [r for r in filled if r["title"].lower() not in chosen]
|
| 557 |
|
| 558 |
-
#
|
| 559 |
for rec in recs:
|
| 560 |
-
if
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
logger.debug(f"Unpaywall enrichment failed for {rec['doi']}: {e}")
|
| 568 |
|
| 569 |
return recs[:limit]
|
| 570 |
|
|
@@ -579,9 +394,8 @@ class PaperRecommender:
|
|
| 579 |
keywords: Optional[List[str]] = None,
|
| 580 |
) -> List[Dict]:
|
| 581 |
"""
|
| 582 |
-
Raw academic search (OpenAlex + Crossref + arXiv
|
| 583 |
-
|
| 584 |
-
for evidence gathering.
|
| 585 |
"""
|
| 586 |
query_terms = extract_query(query, keywords)
|
| 587 |
q = " ".join(query_terms[:4]) if query_terms else query.strip()
|
|
|
|
| 11 |
1. OpenAlex — broad aggregator (publishers, repositories)
|
| 12 |
2. Crossref — publisher metadata (Elsevier, Springer, IEEE, Wiley, ...)
|
| 13 |
3. arXiv — open-access preprint repository (arxiv.org direct links)
|
| 14 |
+
4. Europe PMC — open-access life-science literature
|
| 15 |
+
5. PubMed — biomedical literature (E-utilities)
|
| 16 |
+
All keyless; Unpaywall adds open-access links to the top results.
|
| 17 |
+
|
| 18 |
+
The provider functions themselves live in `services/academic_search.py`
|
| 19 |
+
(shared with the similarity corpus providers - see plan.md §7.3) and are
|
| 20 |
+
re-imported here so the public surface of this module is unchanged.
|
| 21 |
"""
|
| 22 |
import asyncio
|
| 23 |
import math
|
| 24 |
import re
|
| 25 |
from typing import Dict, List, Optional, Tuple
|
|
|
|
| 26 |
|
| 27 |
import httpx
|
| 28 |
|
| 29 |
+
from services.academic_search import (
|
| 30 |
+
DOI_PUBLISHER,
|
| 31 |
+
PROVIDER_SEM,
|
| 32 |
+
_search_arxiv,
|
| 33 |
+
_search_crossref,
|
| 34 |
+
_search_openalex,
|
| 35 |
+
clean_title,
|
| 36 |
+
source_for,
|
| 37 |
+
)
|
| 38 |
from tools.search_tool import EuropePMCTool, PubMedTool, UnpaywallTool
|
| 39 |
from utils.logger import get_logger
|
|
|
|
| 40 |
|
| 41 |
logger = get_logger("paper_recommender")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
STOPWORDS = {
|
| 44 |
"a", "an", "the", "of", "on", "in", "to", "for", "and", "or", "is", "are",
|
| 45 |
"was", "were", "be", "been", "being", "it", "its", "this", "that", "these",
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
|
|
| 92 |
|
| 93 |
# Tokens too weak/generic to help search queries ("for example", "even",
|
| 94 |
# "using"...) plus pure-numeric noise like "1m", "2m".
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def tokenize(text: str) -> List[str]:
|
| 105 |
"""Gap/query tokens: stopwords + gap-phrasing fillers removed, deduped."""
|
| 106 |
if not text:
|
|
|
|
| 265 |
|
| 266 |
# ── Providers ────────────────────────────────────────────────────────────────
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
async def _search_all_providers(
|
| 270 |
client: httpx.AsyncClient, query: str, limit: int = 10
|
|
|
|
| 274 |
results: List[Dict] = []
|
| 275 |
epmc = EuropePMCTool()
|
| 276 |
pubmed = PubMedTool()
|
| 277 |
+
async with PROVIDER_SEM:
|
| 278 |
for fn in (_search_openalex, _search_crossref, _search_arxiv):
|
| 279 |
try:
|
| 280 |
results.extend(await fn(client, query, limit))
|
|
|
|
| 289 |
await asyncio.sleep(0.25)
|
| 290 |
return results
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
class PaperRecommender:
|
| 294 |
"""Web-based paper recommender (OpenAlex + Crossref + arXiv + Europe PMC
|
|
|
|
| 371 |
chosen = {r["title"].lower() for r in recs}
|
| 372 |
recs += [r for r in filled if r["title"].lower() not in chosen]
|
| 373 |
|
| 374 |
+
# Unpaywall enrichment: attach a direct open-access URL where possible.
|
| 375 |
for rec in recs:
|
| 376 |
+
if rec.get("doi"):
|
| 377 |
+
try:
|
| 378 |
+
result = await self._unpaywall.run(rec["doi"])
|
| 379 |
+
if result.success and result.data and result.data.get("url"):
|
| 380 |
+
rec["oa_url"] = result.data["url"]
|
| 381 |
+
except Exception as e:
|
| 382 |
+
logger.debug(f"Unpaywall enrichment failed for {rec['doi']}: {e}")
|
|
|
|
| 383 |
|
| 384 |
return recs[:limit]
|
| 385 |
|
|
|
|
| 394 |
keywords: Optional[List[str]] = None,
|
| 395 |
) -> List[Dict]:
|
| 396 |
"""
|
| 397 |
+
Raw academic search (OpenAlex + Crossref + arXiv) without ranking:
|
| 398 |
+
returns candidates labeled with source, for evidence gathering.
|
|
|
|
| 399 |
"""
|
| 400 |
query_terms = extract_query(query, keywords)
|
| 401 |
q = " ".join(query_terms[:4]) if query_terms else query.strip()
|
similarity/__init__.py
ADDED
|
File without changes
|
similarity/aggregate.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregation: the coverage bitmap and every percentage in the report.
|
| 2 |
+
|
| 3 |
+
The single data structure the whole score rests on (plan.md §4.2):
|
| 4 |
+
|
| 5 |
+
covered[i] = source_index that owns word i, or None
|
| 6 |
+
|
| 7 |
+
A word is counted once no matter how many sources contain it - this is what
|
| 8 |
+
stops percentages exceeding 100% when boilerplate appears in forty papers.
|
| 9 |
+
Attribution (§4.3) goes to the source with the longest confirmed match
|
| 10 |
+
overlapping that word; ties break on provider trust. Bucket percentages (§4.4)
|
| 11 |
+
are computed independently, as "the index if this were the only bucket" - they
|
| 12 |
+
deliberately do not sum to the headline number.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from .schema import Bucket
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Match:
|
| 24 |
+
"""One confirmed overlap, ready for the bitmap."""
|
| 25 |
+
|
| 26 |
+
source_index: int
|
| 27 |
+
bucket: Bucket
|
| 28 |
+
trust: int # higher preferred when lengths tie
|
| 29 |
+
doc_start: int # inclusive
|
| 30 |
+
doc_end: int # exclusive
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class ScoreResult:
|
| 35 |
+
covered: list[int | None] # per-word owning source index
|
| 36 |
+
overall_percent: int
|
| 37 |
+
per_source: dict[int, int] # source_index -> attributed words
|
| 38 |
+
per_source_percent: dict[int, int] # source_index -> percent
|
| 39 |
+
bucket_words: dict[Bucket, int] # words covered by ANY source in bucket
|
| 40 |
+
bucket_percents: dict[Bucket, int] # bucket "as if it were the only one"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _pct(count: int, total: int) -> int:
|
| 44 |
+
if total <= 0:
|
| 45 |
+
return 0
|
| 46 |
+
return round(100 * count / total)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compute_percentages(
|
| 50 |
+
total_words: int,
|
| 51 |
+
matches: list[Match],
|
| 52 |
+
min_match_words: int = 8,
|
| 53 |
+
) -> ScoreResult:
|
| 54 |
+
"""Fill the coverage bitmap and derive all four percentage families."""
|
| 55 |
+
kept = [m for m in matches if m.doc_end - m.doc_start >= min_match_words]
|
| 56 |
+
|
| 57 |
+
covered: list[int | None] = [None] * total_words
|
| 58 |
+
best_len = [0] * total_words
|
| 59 |
+
best_trust = [0] * total_words
|
| 60 |
+
|
| 61 |
+
for m in kept:
|
| 62 |
+
length = m.doc_end - m.doc_start
|
| 63 |
+
for i in range(m.doc_start, min(m.doc_end, total_words)):
|
| 64 |
+
if covered[i] is None or (length, m.trust) > (best_len[i], best_trust[i]):
|
| 65 |
+
covered[i] = m.source_index
|
| 66 |
+
best_len[i] = length
|
| 67 |
+
best_trust[i] = m.trust
|
| 68 |
+
|
| 69 |
+
covered_count = sum(1 for c in covered if c is not None)
|
| 70 |
+
|
| 71 |
+
per_source: dict[int, int] = {}
|
| 72 |
+
for c in covered:
|
| 73 |
+
if c is not None:
|
| 74 |
+
per_source[c] = per_source.get(c, 0) + 1
|
| 75 |
+
per_source_percent = {s: _pct(n, total_words) for s, n in per_source.items()}
|
| 76 |
+
|
| 77 |
+
# Only buckets that actually contributed matches are reported; a bucket
|
| 78 |
+
# with no confirmed match must not claim it was "checked and clean".
|
| 79 |
+
bucket_words: dict[Bucket, int] = {}
|
| 80 |
+
bucket_percents: dict[Bucket, int] = {}
|
| 81 |
+
for bucket in {m.bucket for m in kept}:
|
| 82 |
+
seen: set[int] = set()
|
| 83 |
+
for m in kept:
|
| 84 |
+
if m.bucket is not bucket:
|
| 85 |
+
continue
|
| 86 |
+
for i in range(m.doc_start, min(m.doc_end, total_words)):
|
| 87 |
+
seen.add(i)
|
| 88 |
+
bucket_words[bucket] = len(seen)
|
| 89 |
+
bucket_percents[bucket] = _pct(len(seen), total_words)
|
| 90 |
+
|
| 91 |
+
return ScoreResult(
|
| 92 |
+
covered=covered,
|
| 93 |
+
overall_percent=_pct(covered_count, total_words),
|
| 94 |
+
per_source=per_source,
|
| 95 |
+
per_source_percent=per_source_percent,
|
| 96 |
+
bucket_words=bucket_words,
|
| 97 |
+
bucket_percents=bucket_percents,
|
| 98 |
+
)
|
similarity/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Settings for the corpus-similarity package.
|
| 2 |
+
|
| 3 |
+
Namespaced under the SIM_ env prefix so it cannot collide with CitationEdge's
|
| 4 |
+
own configuration, and `extra="ignore"` so the shared .env (Groq/Neo4j/Mongo
|
| 5 |
+
keys) is read without complaint. The similarity pipeline ships ENABLED but its
|
| 6 |
+
corpus indexing ships OFF: indexing users' papers into a shared corpus is a
|
| 7 |
+
consent decision, not a default (see plan.md §16 risk 6).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SimilaritySettings(BaseSettings):
|
| 16 |
+
model_config = SettingsConfigDict(
|
| 17 |
+
env_prefix="SIM_",
|
| 18 |
+
env_file=".env",
|
| 19 |
+
extra="ignore",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# -- Master switch ---------------------------------------------
|
| 23 |
+
enabled: bool = True
|
| 24 |
+
core_api_key: str = "" # CORE v3 key; empty => unauthenticated (rate-limited)
|
| 25 |
+
|
| 26 |
+
# -- Per-provider toggles --------------------------------------
|
| 27 |
+
enable_core: bool = True
|
| 28 |
+
enable_arxiv: bool = True
|
| 29 |
+
enable_openalex: bool = True
|
| 30 |
+
enable_crossref: bool = True
|
| 31 |
+
enable_internal: bool = True
|
| 32 |
+
# Index this document's fingerprints into the internal corpus so it can be
|
| 33 |
+
# matched against later submissions. SHIPS OFF - a consent decision.
|
| 34 |
+
index_uploads: bool = False
|
| 35 |
+
|
| 36 |
+
# -- Fingerprinting --------------------------------------------
|
| 37 |
+
kgram_size: int = 5 # winnowing k, in words
|
| 38 |
+
window_size: int = 4 # winnowing w; detects any shared run >= w+k-1
|
| 39 |
+
min_match_words: int = 8 # minimum reportable match length
|
| 40 |
+
min_document_words: int = 300 # below this => unavailable
|
| 41 |
+
|
| 42 |
+
# -- Retrieval budget ------------------------------------------
|
| 43 |
+
max_queries: int = 24 # phrase queries per document
|
| 44 |
+
max_candidates: int = 400 # candidates verified per document
|
| 45 |
+
max_pdf_fetches: int = 5 # OA PDFs downloaded per document
|
| 46 |
+
budget_seconds: int = 45 # hard wall-clock cap on retrieval
|
| 47 |
+
|
| 48 |
+
# -- Exclusions ------------------------------------------------
|
| 49 |
+
exclude_quotes: bool = True
|
| 50 |
+
exclude_bibliography: bool = True
|
| 51 |
+
|
| 52 |
+
# -- Paraphrase signal (never in the headline %) ---------------
|
| 53 |
+
paraphrase_threshold: float = 0.92
|
| 54 |
+
enable_paraphrase: bool = True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
settings = SimilaritySettings()
|
similarity/corpus/__init__.py
ADDED
|
File without changes
|
similarity/corpus/arxiv.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""arXiv provider (plan.md §7.2).
|
| 2 |
+
|
| 3 |
+
Metadata comes from the shared `_search_arxiv` (reused verbatim from
|
| 4 |
+
`services/academic_search.py`, itself lifted from paper_recommender - never
|
| 5 |
+
copied). Full text is fetched by downloading the OA PDF and extracting it
|
| 6 |
+
with the existing `utils.pdf.extract_text_from_pdf`, capped at
|
| 7 |
+
`SIM_MAX_PDF_FETCHES`. PDF fetch + parse is the slowest thing in the
|
| 8 |
+
pipeline, so the cap and the shared semaphore matter.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import tempfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import httpx
|
| 18 |
+
|
| 19 |
+
from similarity.config import settings
|
| 20 |
+
from similarity.corpus.base import Candidate
|
| 21 |
+
from similarity.schema import Bucket
|
| 22 |
+
from utils.logger import get_logger
|
| 23 |
+
from utils.retry import async_retry
|
| 24 |
+
|
| 25 |
+
logger = get_logger("arxiv_provider")
|
| 26 |
+
|
| 27 |
+
# Shared throttle across all providers (plan.md §7.1).
|
| 28 |
+
_ARXIV_SEM = asyncio.Semaphore(2)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ArxivProvider:
|
| 32 |
+
"""Search arXiv metadata; fetch full text from the OA PDF when asked."""
|
| 33 |
+
|
| 34 |
+
name = "arxiv"
|
| 35 |
+
provider = "arXiv"
|
| 36 |
+
bucket = Bucket.OPEN_ACCESS
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
client: httpx.AsyncClient | None = None,
|
| 41 |
+
max_pdf_fetches: int | None = None,
|
| 42 |
+
max_attempts: int = 3,
|
| 43 |
+
delay: float = 1.0,
|
| 44 |
+
backoff: float = 2.0,
|
| 45 |
+
):
|
| 46 |
+
self._client = client or httpx.AsyncClient()
|
| 47 |
+
self._fetches_left = max_pdf_fetches if max_pdf_fetches is not None else settings.max_pdf_fetches
|
| 48 |
+
self.last_error: str | None = None
|
| 49 |
+
self._search_once = async_retry(
|
| 50 |
+
max_attempts=max_attempts, delay=delay, backoff=backoff,
|
| 51 |
+
exceptions=(httpx.HTTPStatusError, httpx.TransportError),
|
| 52 |
+
)(self._search_once_raw)
|
| 53 |
+
|
| 54 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]:
|
| 55 |
+
try:
|
| 56 |
+
results = await self._search_once(phrase, limit)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
self.last_error = str(e)
|
| 59 |
+
logger.warning(f"arXiv search failed for {phrase[:40]!r}: {e}")
|
| 60 |
+
return []
|
| 61 |
+
candidates: list[Candidate] = []
|
| 62 |
+
for r in results:
|
| 63 |
+
url = (r.get("url") or "").strip()
|
| 64 |
+
if not url:
|
| 65 |
+
continue
|
| 66 |
+
candidates.append(
|
| 67 |
+
Candidate(
|
| 68 |
+
candidate_id=r.get("paper_id") or url,
|
| 69 |
+
title=r.get("title", ""),
|
| 70 |
+
url=url,
|
| 71 |
+
doi=r.get("doi") or None,
|
| 72 |
+
authors=list(r.get("authors") or []),
|
| 73 |
+
year=r.get("year"),
|
| 74 |
+
provider=self.provider,
|
| 75 |
+
bucket=self.bucket,
|
| 76 |
+
text=r.get("abstract") or None,
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
return candidates[:limit]
|
| 80 |
+
|
| 81 |
+
async def fetch_text(self, candidate: Candidate) -> str | None:
|
| 82 |
+
"""Download the OA PDF and extract its text; budget-capped."""
|
| 83 |
+
if self._fetches_left <= 0:
|
| 84 |
+
logger.warning("arXiv PDF fetch budget exhausted")
|
| 85 |
+
return None
|
| 86 |
+
pdf_url = candidate.url.replace("/abs/", "/pdf/")
|
| 87 |
+
if not pdf_url.endswith(".pdf"):
|
| 88 |
+
pdf_url = f"{pdf_url}.pdf"
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
text = await self._download_and_extract(pdf_url)
|
| 92 |
+
except Exception as e: # noqa: BLE001 - a bad PDF must not kill the run
|
| 93 |
+
self.last_error = str(e)
|
| 94 |
+
logger.warning(f"arXiv PDF fetch failed for {candidate.url}: {e}")
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
self._fetches_left -= 1
|
| 98 |
+
return text
|
| 99 |
+
|
| 100 |
+
async def _download_and_extract(self, pdf_url: str) -> str:
|
| 101 |
+
async with _ARXIV_SEM:
|
| 102 |
+
await asyncio.sleep(0.25)
|
| 103 |
+
resp = await self._client.get(pdf_url, timeout=30, follow_redirects=True)
|
| 104 |
+
resp.raise_for_status()
|
| 105 |
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 106 |
+
tmp.write(resp.content)
|
| 107 |
+
tmp_path = tmp.name
|
| 108 |
+
try:
|
| 109 |
+
from utils.pdf import extract_text_from_pdf
|
| 110 |
+
result = extract_text_from_pdf(tmp_path)
|
| 111 |
+
finally:
|
| 112 |
+
Path(tmp_path).unlink(missing_ok=True)
|
| 113 |
+
full_text = result.get("full_text") or ""
|
| 114 |
+
return full_text if full_text.strip() else None
|
| 115 |
+
|
| 116 |
+
async def _search_once_raw(self, phrase: str, limit: int) -> list[dict]:
|
| 117 |
+
from services.academic_search import _search_arxiv
|
| 118 |
+
async with _ARXIV_SEM:
|
| 119 |
+
await asyncio.sleep(0.25)
|
| 120 |
+
return await _search_arxiv(self._client, phrase, limit)
|
similarity/corpus/base.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus provider protocol and the candidate record every provider returns.
|
| 2 |
+
|
| 3 |
+
Plan.md §7: all providers (CORE, arXiv, OpenAlex, Crossref, internal corpus)
|
| 4 |
+
implement one interface so the pipeline is provider-agnostic and each can be
|
| 5 |
+
switched off independently. A `Candidate` is the *proposal* stage of the
|
| 6 |
+
two-stage design - stage two (verification against real text) lives in
|
| 7 |
+
`similarity.matcher`.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Protocol
|
| 14 |
+
|
| 15 |
+
from similarity.schema import Bucket
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class Candidate:
|
| 20 |
+
"""A document worth verifying, returned by a provider's phrase search."""
|
| 21 |
+
|
| 22 |
+
source_index: int | None = None # assigned by the pipeline once ranked
|
| 23 |
+
candidate_id: str = ""
|
| 24 |
+
title: str = ""
|
| 25 |
+
url: str = "" # must be resolvable before it can become evidence
|
| 26 |
+
doi: str | None = None
|
| 27 |
+
authors: list[str] = field(default_factory=list)
|
| 28 |
+
year: int | None = None
|
| 29 |
+
provider: str = "" # "CORE" | "arXiv" | "Crossref" | "OpenAlex" | "CitationEdge Corpus"
|
| 30 |
+
bucket: Bucket | None = None
|
| 31 |
+
text: str | None = None # pre-fetched full text (CORE returns it inline)
|
| 32 |
+
submitted_at: str = "" # internal corpus: neutral "submitted YYYY-MM-DD" label
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CorpusProvider(Protocol):
|
| 36 |
+
"""Anything that can propose candidates for a phrase and fetch their text."""
|
| 37 |
+
|
| 38 |
+
name: str
|
| 39 |
+
bucket: Bucket
|
| 40 |
+
|
| 41 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]: ...
|
| 42 |
+
|
| 43 |
+
async def fetch_text(self, candidate: Candidate) -> str | None: ...
|
similarity/corpus/core_api.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CORE v3 — the full-text workhorse (plan.md §7.1).
|
| 2 |
+
|
| 3 |
+
`GET https://api.core.ac.uk/v3/search/works?q=fullText:"<phrase>"` returns
|
| 4 |
+
records that can carry the FULL TEXT inline, so retrieval and text fetch
|
| 5 |
+
collapse into one round trip. The unauthenticated limit is ~5 requests /
|
| 6 |
+
10 seconds, which 24 phrase queries would blow through immediately - hence
|
| 7 |
+
the startup warning when `SIM_CORE_API_KEY` is unset, the shared semaphore,
|
| 8 |
+
and the stagger between calls.
|
| 9 |
+
|
| 10 |
+
Failures are surfaced, never swallowed: after `@async_retry` exhausts, the
|
| 11 |
+
provider records `last_error` and returns `[]`, so the pipeline can append the
|
| 12 |
+
provider name to `coverage.providers_failed` (plan.md §16 risk 4).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
from similarity.config import settings
|
| 22 |
+
from similarity.corpus.base import Candidate
|
| 23 |
+
from similarity.schema import Bucket
|
| 24 |
+
from utils.logger import get_logger
|
| 25 |
+
from utils.retry import async_retry
|
| 26 |
+
|
| 27 |
+
logger = get_logger("core_api")
|
| 28 |
+
|
| 29 |
+
CORE_SEARCH_URL = "https://api.core.ac.uk/v3/search/works"
|
| 30 |
+
|
| 31 |
+
# Shared throttle across ALL providers (plan.md §7.1): bursts 429 instantly.
|
| 32 |
+
_CORE_SEM = asyncio.Semaphore(2)
|
| 33 |
+
|
| 34 |
+
if not settings.core_api_key:
|
| 35 |
+
logger.warning(
|
| 36 |
+
"SIM_CORE_API_KEY is unset: CORE will run unauthenticated "
|
| 37 |
+
"(~5 requests / 10s) and rate-limit immediately. Register at "
|
| 38 |
+
"core.ac.uk/docs/ to get a key."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class CoreApiProvider:
|
| 43 |
+
"""Full-text search + fetch from CORE, one round trip per phrase."""
|
| 44 |
+
|
| 45 |
+
name = "core"
|
| 46 |
+
provider = "CORE"
|
| 47 |
+
bucket = Bucket.OPEN_ACCESS
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
api_key: str | None = None,
|
| 52 |
+
client: httpx.AsyncClient | None = None,
|
| 53 |
+
max_attempts: int = 3,
|
| 54 |
+
delay: float = 1.0,
|
| 55 |
+
backoff: float = 2.0,
|
| 56 |
+
):
|
| 57 |
+
self._api_key = (api_key if api_key is not None else settings.core_api_key) or ""
|
| 58 |
+
self._client = client or httpx.AsyncClient()
|
| 59 |
+
self.last_error: str | None = None
|
| 60 |
+
self._search_once = async_retry(
|
| 61 |
+
max_attempts=max_attempts, delay=delay, backoff=backoff,
|
| 62 |
+
exceptions=(httpx.HTTPStatusError, httpx.TransportError),
|
| 63 |
+
)(self._search_once_raw)
|
| 64 |
+
|
| 65 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]:
|
| 66 |
+
try:
|
| 67 |
+
data = await self._search_once(phrase, limit)
|
| 68 |
+
results = data.get("results", []) if isinstance(data, dict) else []
|
| 69 |
+
return [self._to_candidate(r) for r in results[:limit]]
|
| 70 |
+
except Exception as e:
|
| 71 |
+
self.last_error = str(e)
|
| 72 |
+
logger.warning(f"CORE search failed for {phrase[:40]!r}: {e}")
|
| 73 |
+
return []
|
| 74 |
+
|
| 75 |
+
async def fetch_text(self, candidate: Candidate) -> str | None:
|
| 76 |
+
"""CORE returns the full text inline at search time."""
|
| 77 |
+
return candidate.text
|
| 78 |
+
|
| 79 |
+
async def _search_once_raw(self, phrase: str, limit: int) -> dict:
|
| 80 |
+
headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
|
| 81 |
+
params = {"q": f'fullText:"{phrase}"', "limit": limit, "scroll": False}
|
| 82 |
+
async with _CORE_SEM:
|
| 83 |
+
await asyncio.sleep(0.25) # stagger, kind to rate limits
|
| 84 |
+
resp = await self._client.get(
|
| 85 |
+
CORE_SEARCH_URL, params=params, headers=headers, timeout=20
|
| 86 |
+
)
|
| 87 |
+
resp.raise_for_status()
|
| 88 |
+
return resp.json()
|
| 89 |
+
|
| 90 |
+
def _to_candidate(self, record: dict) -> Candidate:
|
| 91 |
+
title = (record.get("title") or "").strip()
|
| 92 |
+
url = (record.get("downloadUrl") or record.get("fullTextUrl") or "").strip()
|
| 93 |
+
if not url:
|
| 94 |
+
# Metadata-only records carry no download URL; fall back to DOI.
|
| 95 |
+
doi = (record.get("doi") or "").strip()
|
| 96 |
+
if doi:
|
| 97 |
+
url = f"https://doi.org/{doi}"
|
| 98 |
+
return Candidate(
|
| 99 |
+
candidate_id=(record.get("id") or url or title),
|
| 100 |
+
title=title,
|
| 101 |
+
url=url,
|
| 102 |
+
doi=(record.get("doi") or "").strip() or None,
|
| 103 |
+
authors=[a for a in (record.get("authors") or []) if a][:10],
|
| 104 |
+
year=record.get("yearPublished"),
|
| 105 |
+
provider=self.provider,
|
| 106 |
+
bucket=self.bucket,
|
| 107 |
+
text=record.get("fullText") or record.get("abstract") or None,
|
| 108 |
+
)
|
similarity/corpus/crossref.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crossref provider (plan.md §7.3).
|
| 2 |
+
|
| 3 |
+
Abstract-only matching, reusing the shared `_search_crossref` verbatim. Like
|
| 4 |
+
OpenAlex these are mostly 1% PUBLICATION-bucket entries; the DOI prefix map
|
| 5 |
+
(`DOI_PUBLISHER`) labels them as Elsevier / Springer / IEEE / ... exactly as
|
| 6 |
+
the reference screenshot shows.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
from services.academic_search import DOI_PUBLISHER, _search_crossref, source_for
|
| 16 |
+
from similarity.corpus.base import Candidate
|
| 17 |
+
from similarity.schema import Bucket
|
| 18 |
+
from utils.logger import get_logger
|
| 19 |
+
from utils.retry import async_retry
|
| 20 |
+
|
| 21 |
+
logger = get_logger("crossref_provider")
|
| 22 |
+
|
| 23 |
+
# Shared throttle across all providers (plan.md §7.1).
|
| 24 |
+
_CROSSREF_SEM = asyncio.Semaphore(2)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CrossrefProvider:
|
| 28 |
+
"""Search Crossref metadata; the abstract is the only retrievable text."""
|
| 29 |
+
|
| 30 |
+
name = "crossref"
|
| 31 |
+
provider = "Crossref"
|
| 32 |
+
bucket = Bucket.PUBLICATION
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
client: httpx.AsyncClient | None = None,
|
| 37 |
+
max_attempts: int = 3,
|
| 38 |
+
delay: float = 1.0,
|
| 39 |
+
backoff: float = 2.0,
|
| 40 |
+
):
|
| 41 |
+
self._client = client or httpx.AsyncClient()
|
| 42 |
+
self.last_error: str | None = None
|
| 43 |
+
self._search_once = async_retry(
|
| 44 |
+
max_attempts=max_attempts, delay=delay, backoff=backoff,
|
| 45 |
+
exceptions=(httpx.HTTPStatusError, httpx.TransportError),
|
| 46 |
+
)(self._search_once_raw)
|
| 47 |
+
|
| 48 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]:
|
| 49 |
+
try:
|
| 50 |
+
results = await self._search_once(phrase, limit)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
self.last_error = str(e)
|
| 53 |
+
logger.warning(f"Crossref search failed for {phrase[:40]!r}: {e}")
|
| 54 |
+
return []
|
| 55 |
+
candidates: list[Candidate] = []
|
| 56 |
+
for r in results:
|
| 57 |
+
url = (r.get("url") or "").strip()
|
| 58 |
+
if not url:
|
| 59 |
+
continue
|
| 60 |
+
doi = r.get("doi") or None
|
| 61 |
+
label = DOI_PUBLISHER.get((doi or "").split(".")[0] + "." + (doi or "").split(".")[1], "Crossref") if doi and doi.startswith("10.") else source_for(r)
|
| 62 |
+
candidates.append(
|
| 63 |
+
Candidate(
|
| 64 |
+
candidate_id=r.get("paper_id") or url,
|
| 65 |
+
title=r.get("title", ""),
|
| 66 |
+
url=url,
|
| 67 |
+
doi=doi,
|
| 68 |
+
authors=list(r.get("authors") or []),
|
| 69 |
+
year=r.get("year"),
|
| 70 |
+
provider=self.provider,
|
| 71 |
+
bucket=self.bucket,
|
| 72 |
+
text=r.get("abstract") or None,
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
return candidates[:limit]
|
| 76 |
+
|
| 77 |
+
async def fetch_text(self, candidate: Candidate) -> str | None:
|
| 78 |
+
"""Crossref serves abstracts only - there is no full text to fetch."""
|
| 79 |
+
return candidate.text
|
| 80 |
+
|
| 81 |
+
async def _search_once_raw(self, phrase: str, limit: int) -> list[dict]:
|
| 82 |
+
async with _CROSSREF_SEM:
|
| 83 |
+
await asyncio.sleep(0.25)
|
| 84 |
+
return await _search_crossref(self._client, phrase, limit)
|
similarity/corpus/internal.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The internal corpus provider: prior CitationEdge uploads (plan.md §7.4).
|
| 2 |
+
|
| 3 |
+
Read path: fingerprint the query phrase, ask the Mongo multikey index for any
|
| 4 |
+
stored document sharing a fingerprint, then hand back the raw text so
|
| 5 |
+
`similarity.matcher` can verify locally. The winnowing guarantee transfers
|
| 6 |
+
verbatim: if the phrase (>= w+k-1 words) appears inside a stored document,
|
| 7 |
+
the two fingerprint sets intersect, so the candidate is found.
|
| 8 |
+
|
| 9 |
+
Privacy is enforced HERE, at construction, not in the UI where a future
|
| 10 |
+
refactor could silently drop it: `SourceMatch.title` and `.url` are replaced
|
| 11 |
+
with a neutral submission-date label, `authors` is emptied, and the
|
| 12 |
+
`source_excerpt` (text belonging to the *other* researcher's paper) is
|
| 13 |
+
stripped from every span.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from similarity.config import settings
|
| 19 |
+
from similarity.corpus.base import Candidate
|
| 20 |
+
from similarity.fingerprint import fingerprint
|
| 21 |
+
from similarity.normalize import normalize
|
| 22 |
+
from similarity.schema import Bucket, SourceMatch
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _submitted_label(created_at) -> str:
|
| 26 |
+
"""'CitationEdge Corpus · submitted YYYY-MM-DD' from a stored row."""
|
| 27 |
+
if hasattr(created_at, "date"):
|
| 28 |
+
date = created_at.date().isoformat()
|
| 29 |
+
else:
|
| 30 |
+
date = str(created_at or "")[:10] or "unknown-date"
|
| 31 |
+
return f"CitationEdge Corpus · submitted {date}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class InternalCorpusProvider:
|
| 35 |
+
"""Search prior uploads and verify matches against their stored text."""
|
| 36 |
+
|
| 37 |
+
name = "internal"
|
| 38 |
+
bucket = Bucket.INTERNAL
|
| 39 |
+
provider = "CitationEdge Corpus"
|
| 40 |
+
|
| 41 |
+
def __init__(self, mongo, *, doc_id: str, job_id: str | None = None,
|
| 42 |
+
k: int | None = None, w: int | None = None):
|
| 43 |
+
self._mongo = mongo
|
| 44 |
+
self._doc_id = doc_id
|
| 45 |
+
self._job_id = job_id
|
| 46 |
+
self._k = k or settings.kgram_size
|
| 47 |
+
self._w = w or settings.window_size
|
| 48 |
+
|
| 49 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]:
|
| 50 |
+
if not settings.enable_internal:
|
| 51 |
+
return []
|
| 52 |
+
nd = normalize(phrase)
|
| 53 |
+
fps = fingerprint(nd.words, self._k, self._w)
|
| 54 |
+
if not fps:
|
| 55 |
+
return []
|
| 56 |
+
|
| 57 |
+
db = self._mongo._get_db()
|
| 58 |
+
exclude = {"doc_id": {"$ne": self._doc_id}}
|
| 59 |
+
if self._job_id:
|
| 60 |
+
exclude["job_id"] = {"$ne": self._job_id}
|
| 61 |
+
query = {"fingerprints": {"$in": list(fps)}, **exclude}
|
| 62 |
+
|
| 63 |
+
rows = await db.corpus_fingerprints.find(query).limit(limit).to_list(limit)
|
| 64 |
+
candidates: list[Candidate] = []
|
| 65 |
+
for row in rows:
|
| 66 |
+
candidates.append(
|
| 67 |
+
Candidate(
|
| 68 |
+
candidate_id=row["doc_id"],
|
| 69 |
+
title="",
|
| 70 |
+
url="",
|
| 71 |
+
provider=self.provider,
|
| 72 |
+
bucket=self.bucket,
|
| 73 |
+
submitted_at=_submitted_label(row.get("created_at")),
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
return candidates
|
| 77 |
+
|
| 78 |
+
async def fetch_text(self, candidate: Candidate) -> str | None:
|
| 79 |
+
db = self._mongo._get_db()
|
| 80 |
+
row = await db.corpus_texts.find_one({"doc_id": candidate.candidate_id})
|
| 81 |
+
return row["normalized_text"] if row else None
|
| 82 |
+
|
| 83 |
+
def build_source_match(
|
| 84 |
+
self,
|
| 85 |
+
candidate: Candidate,
|
| 86 |
+
*,
|
| 87 |
+
source_index: int,
|
| 88 |
+
matched_words: int,
|
| 89 |
+
percent: int,
|
| 90 |
+
spans,
|
| 91 |
+
) -> SourceMatch:
|
| 92 |
+
"""Construct a SourceMatch with the privacy boundary applied."""
|
| 93 |
+
neutral = candidate.submitted_at or _submitted_label(None)
|
| 94 |
+
cleaned_spans = []
|
| 95 |
+
for span in spans:
|
| 96 |
+
cleaned_spans.append(
|
| 97 |
+
span.model_copy(update={"source_excerpt": ""})
|
| 98 |
+
)
|
| 99 |
+
return SourceMatch(
|
| 100 |
+
source_index=source_index,
|
| 101 |
+
bucket=self.bucket,
|
| 102 |
+
title=neutral,
|
| 103 |
+
url=neutral,
|
| 104 |
+
doi=None,
|
| 105 |
+
authors=[],
|
| 106 |
+
year=None,
|
| 107 |
+
provider=self.provider,
|
| 108 |
+
display_label=neutral,
|
| 109 |
+
matched_words=matched_words,
|
| 110 |
+
percent=percent,
|
| 111 |
+
spans=cleaned_spans,
|
| 112 |
+
)
|
similarity/corpus/openalex.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAlex provider (plan.md §7.3).
|
| 2 |
+
|
| 3 |
+
Abstract-only matching, reusing the shared `_search_openalex` verbatim. An
|
| 4 |
+
abstract-level match is real but small, so these mostly populate the
|
| 5 |
+
`PUBLICATION` bucket with 1% entries - exactly what the reference screenshot
|
| 6 |
+
shows for Springer. `source_for` + `DOI_PUBLISHER` supply the display label.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
from services.academic_search import DOI_PUBLISHER, _search_openalex, source_for
|
| 16 |
+
from similarity.corpus.base import Candidate
|
| 17 |
+
from similarity.schema import Bucket
|
| 18 |
+
from utils.logger import get_logger
|
| 19 |
+
from utils.retry import async_retry
|
| 20 |
+
|
| 21 |
+
logger = get_logger("openalex_provider")
|
| 22 |
+
|
| 23 |
+
# Shared throttle across all providers (plan.md §7.1).
|
| 24 |
+
_OPENALEX_SEM = asyncio.Semaphore(2)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class OpenAlexProvider:
|
| 28 |
+
"""Search OpenAlex metadata; the abstract is the only retrievable text."""
|
| 29 |
+
|
| 30 |
+
name = "openalex"
|
| 31 |
+
provider = "OpenAlex"
|
| 32 |
+
bucket = Bucket.PUBLICATION
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
client: httpx.AsyncClient | None = None,
|
| 37 |
+
max_attempts: int = 3,
|
| 38 |
+
delay: float = 1.0,
|
| 39 |
+
backoff: float = 2.0,
|
| 40 |
+
):
|
| 41 |
+
self._client = client or httpx.AsyncClient()
|
| 42 |
+
self.last_error: str | None = None
|
| 43 |
+
self._search_once = async_retry(
|
| 44 |
+
max_attempts=max_attempts, delay=delay, backoff=backoff,
|
| 45 |
+
exceptions=(httpx.HTTPStatusError, httpx.TransportError),
|
| 46 |
+
)(self._search_once_raw)
|
| 47 |
+
|
| 48 |
+
async def search_phrase(self, phrase: str, limit: int) -> list[Candidate]:
|
| 49 |
+
try:
|
| 50 |
+
results = await self._search_once(phrase, limit)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
self.last_error = str(e)
|
| 53 |
+
logger.warning(f"OpenAlex search failed for {phrase[:40]!r}: {e}")
|
| 54 |
+
return []
|
| 55 |
+
candidates: list[Candidate] = []
|
| 56 |
+
for r in results:
|
| 57 |
+
url = (r.get("url") or "").strip()
|
| 58 |
+
if not url:
|
| 59 |
+
continue
|
| 60 |
+
title = r.get("title", "")
|
| 61 |
+
doi = r.get("doi") or None
|
| 62 |
+
label = DOI_PUBLISHER.get((doi or "").split(".")[0] + "." + (doi or "").split(".")[1], "OpenAlex") if doi and doi.startswith("10.") else source_for(r)
|
| 63 |
+
candidates.append(
|
| 64 |
+
Candidate(
|
| 65 |
+
candidate_id=r.get("paper_id") or url,
|
| 66 |
+
title=title,
|
| 67 |
+
url=url,
|
| 68 |
+
doi=doi,
|
| 69 |
+
authors=list(r.get("authors") or []),
|
| 70 |
+
year=r.get("year"),
|
| 71 |
+
provider=self.provider,
|
| 72 |
+
bucket=self.bucket,
|
| 73 |
+
text=r.get("abstract") or None,
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
return candidates[:limit]
|
| 77 |
+
|
| 78 |
+
async def fetch_text(self, candidate: Candidate) -> str | None:
|
| 79 |
+
"""OpenAlex serves abstracts only - there is no full text to fetch."""
|
| 80 |
+
return candidate.text
|
| 81 |
+
|
| 82 |
+
async def _search_once_raw(self, phrase: str, limit: int) -> list[dict]:
|
| 83 |
+
async with _OPENALEX_SEM:
|
| 84 |
+
await asyncio.sleep(0.25)
|
| 85 |
+
return await _search_openalex(self._client, phrase, limit)
|
similarity/exclusions.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exclusion-region detection for the similarity denominator.
|
| 2 |
+
|
| 3 |
+
Plan.md §4.1: bibliography, quoted text, title block, equations and captions
|
| 4 |
+
are all excluded by default (each independently toggleable) because they are
|
| 5 |
+
*expected* to coincide with other papers - reference strings are supposed to
|
| 6 |
+
be identical to the original, notation collides across a field, addresses
|
| 7 |
+
match thousands of papers. The remaining words are the ones a similarity
|
| 8 |
+
percentage can honestly claim anything about.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import bisect
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
from .normalize import NormalizedDoc
|
| 18 |
+
|
| 19 |
+
_QUOTE_RE = re.compile(r'"(?:[^"\\]|\\.)*"')
|
| 20 |
+
_BLOCKQUOTE_RE = re.compile(r"^\s*>+\s?.*$", re.MULTILINE)
|
| 21 |
+
_REFERENCE_HEADING_RE = re.compile(r"^\s*(?:\d+\.?\s+)?references?\b", re.IGNORECASE | re.MULTILINE)
|
| 22 |
+
_ABSTRACT_HEADING_RE = re.compile(r"^\s*(?:\d+\.?\s+)?abstract\b", re.IGNORECASE | re.MULTILINE)
|
| 23 |
+
_INTRO_HEADING_RE = re.compile(
|
| 24 |
+
r"^\s*(?:\d+\.?\s+)?introduction\b", re.IGNORECASE | re.MULTILINE
|
| 25 |
+
)
|
| 26 |
+
# An equation-looking span: an "=" (or LaTeX construct) with a short tail to
|
| 27 |
+
# the end of the line. Equations are excluded because notation collides across
|
| 28 |
+
# a whole field, not because they carry similarity signal.
|
| 29 |
+
_EQUATION_LINE_RE = re.compile(r"[^=\n]{0,60}=[^=\n]{0,160}$", re.MULTILINE)
|
| 30 |
+
_LATEX_LINE_RE = re.compile(r"\\frac|\\sum|\\prod|\\begin\{|\\[a-zA-Z]+\{|\$\$", re.MULTILINE)
|
| 31 |
+
_CAPTION_RE = re.compile(r"^\s*(?:figure|fig\.?|table)\s*\d+[:.]", re.IGNORECASE | re.MULTILINE)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class Exclusions:
|
| 36 |
+
"""Word-index ranges that must not contribute to the denominator."""
|
| 37 |
+
|
| 38 |
+
ranges: list[tuple[str, int, int]] # (name, start_word, end_word)
|
| 39 |
+
|
| 40 |
+
def covers(self, word_index: int) -> bool:
|
| 41 |
+
for _, start, end in self.ranges:
|
| 42 |
+
if start <= word_index < end:
|
| 43 |
+
return True
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
def names(self) -> set[str]:
|
| 47 |
+
return {name for name, _, _ in self.ranges}
|
| 48 |
+
|
| 49 |
+
def word_indices(self) -> set[int]:
|
| 50 |
+
out: set[int] = set()
|
| 51 |
+
for _, start, end in self.ranges:
|
| 52 |
+
out.update(range(start, end))
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
def count_words(self) -> int:
|
| 56 |
+
return len(self.word_indices())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _word_range(nd: NormalizedDoc, char_start: int, char_end: int) -> tuple[int, int]:
|
| 60 |
+
"""Inclusive-exclusive word index range covering a char span."""
|
| 61 |
+
if not nd.words:
|
| 62 |
+
return (0, 0)
|
| 63 |
+
start = bisect.bisect_right(nd.starts, char_start) - 1
|
| 64 |
+
end = bisect.bisect_left(nd.starts, char_end)
|
| 65 |
+
return (max(0, start), min(len(nd.words), end))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _word_range_from_char(nd: NormalizedDoc, char_pos: int) -> int:
|
| 69 |
+
"""First word index whose char start is >= char_pos."""
|
| 70 |
+
return bisect.bisect_left(nd.starts, char_pos)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def find_exclusions(
|
| 74 |
+
nd: NormalizedDoc,
|
| 75 |
+
*,
|
| 76 |
+
exclude_bibliography: bool = True,
|
| 77 |
+
exclude_quotes: bool = True,
|
| 78 |
+
exclude_title_block: bool = True,
|
| 79 |
+
exclude_equations: bool = True,
|
| 80 |
+
exclude_captions: bool = True,
|
| 81 |
+
) -> Exclusions:
|
| 82 |
+
"""Detect excluded regions and return them as word-index ranges."""
|
| 83 |
+
text = nd.original_text
|
| 84 |
+
ranges: list[tuple[str, int, int]] = []
|
| 85 |
+
|
| 86 |
+
if exclude_bibliography:
|
| 87 |
+
m = _REFERENCE_HEADING_RE.search(text)
|
| 88 |
+
if m:
|
| 89 |
+
start_w = _word_range_from_char(nd, m.start())
|
| 90 |
+
if start_w < len(nd.words):
|
| 91 |
+
ranges.append(("bibliography", start_w, len(nd.words)))
|
| 92 |
+
|
| 93 |
+
if exclude_quotes:
|
| 94 |
+
for m in _QUOTE_RE.finditer(text):
|
| 95 |
+
ranges.append(("quotes", *_word_range(nd, m.start(), m.end())))
|
| 96 |
+
for m in _BLOCKQUOTE_RE.finditer(text):
|
| 97 |
+
ranges.append(("quotes", *_word_range(nd, m.start(), m.end())))
|
| 98 |
+
|
| 99 |
+
if exclude_title_block:
|
| 100 |
+
heading = _ABSTRACT_HEADING_RE.search(text) or _INTRO_HEADING_RE.search(text)
|
| 101 |
+
if heading:
|
| 102 |
+
start_w = _word_range_from_char(nd, heading.start())
|
| 103 |
+
if 0 < start_w < len(nd.words):
|
| 104 |
+
ranges.append(("title_block", 0, start_w))
|
| 105 |
+
|
| 106 |
+
if exclude_equations:
|
| 107 |
+
for m in _EQUATION_LINE_RE.finditer(text):
|
| 108 |
+
ranges.append(("equations", *_word_range(nd, m.start(), m.end())))
|
| 109 |
+
for m in _LATEX_LINE_RE.finditer(text):
|
| 110 |
+
ranges.append(("equations", *_word_range(nd, m.start(), m.end())))
|
| 111 |
+
|
| 112 |
+
if exclude_captions:
|
| 113 |
+
for m in _CAPTION_RE.finditer(text):
|
| 114 |
+
ranges.append(("captions", *_word_range(nd, m.start(), m.end())))
|
| 115 |
+
|
| 116 |
+
return Exclusions(ranges=ranges)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _merge(ranges: list[tuple[str, int, int]]) -> list[tuple[str, int, int]]:
|
| 120 |
+
"""Sort and merge overlapping ranges (name from the first/leftmost)."""
|
| 121 |
+
ordered = sorted((s, e, n) for n, s, e in ranges if e > s)
|
| 122 |
+
merged: list[tuple[str, int, int]] = []
|
| 123 |
+
for s, e, n in ordered:
|
| 124 |
+
if merged and s <= merged[-1][2]:
|
| 125 |
+
prev_n, prev_s, prev_e = merged[-1]
|
| 126 |
+
merged[-1] = (prev_n, prev_s, max(prev_e, e))
|
| 127 |
+
else:
|
| 128 |
+
merged.append((n, s, e))
|
| 129 |
+
return merged
|
similarity/fingerprint.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""k-gram hashing and winnowing (Schleimer, Wilkerson & Aiken 2003).
|
| 2 |
+
|
| 3 |
+
The detection guarantee that underpins every recall claim in plan.md:
|
| 4 |
+
|
| 5 |
+
any shared contiguous run of >= w + k - 1 words produces an intersecting
|
| 6 |
+
fingerprint set.
|
| 7 |
+
|
| 8 |
+
Sketch: a shared run of `w + k - 1` words contains exactly `w` consecutive
|
| 9 |
+
k-grams. In each document there is a window made up entirely of those w shared
|
| 10 |
+
k-grams, and both documents select the minimum of that same multiset of hashes
|
| 11 |
+
- a common value. The property test in test_fingerprint.py pins this down
|
| 12 |
+
against randomized inputs.
|
| 13 |
+
|
| 14 |
+
The hash is deliberately NOT Python's built-in `hash()`: that is randomized
|
| 15 |
+
per process (PYTHONHASHSEED), so fingerprints stored in the corpus would not
|
| 16 |
+
be reproducible across runs. An FNV-1a over lowercased words keeps values
|
| 17 |
+
stable, deterministic, and case-insensitive.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
_FNV_OFFSET = 0xCBF29CE484222325
|
| 23 |
+
_FNV_PRIME = 0x100000001B3
|
| 24 |
+
_MASK = 0xFFFFFFFFFFFFFFFF
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _stable_hash(words: tuple[str, ...]) -> int:
|
| 28 |
+
h = _FNV_OFFSET
|
| 29 |
+
for w in words:
|
| 30 |
+
for ch in w.lower():
|
| 31 |
+
h ^= ord(ch)
|
| 32 |
+
h = (h * _FNV_PRIME) & _MASK
|
| 33 |
+
return h
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def kgram_hashes(words: list[str], k: int) -> list[int]:
|
| 37 |
+
"""Hashes of every sliding k-gram in `words`, in document order."""
|
| 38 |
+
if k <= 0 or len(words) < k:
|
| 39 |
+
return []
|
| 40 |
+
return [_stable_hash(tuple(words[i:i + k])) for i in range(len(words) - k + 1)]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def winnow(hashes: list[int], w: int) -> set[int]:
|
| 44 |
+
"""Select the minimum of every window of w consecutive k-gram hashes,
|
| 45 |
+
deduplicating consecutive selections of the same position.
|
| 46 |
+
|
| 47 |
+
This is the winnowing step: one fingerprint per window (at most), instead
|
| 48 |
+
of one per k-gram.
|
| 49 |
+
"""
|
| 50 |
+
if not hashes or w <= 0:
|
| 51 |
+
return set()
|
| 52 |
+
if w == 1:
|
| 53 |
+
return set(hashes)
|
| 54 |
+
|
| 55 |
+
selected: set[int] = set()
|
| 56 |
+
prev_pos: int | None = None
|
| 57 |
+
for i in range(len(hashes) - w + 1):
|
| 58 |
+
window = hashes[i:i + w]
|
| 59 |
+
min_h = min(window)
|
| 60 |
+
min_pos = i + window.index(min_h)
|
| 61 |
+
if min_pos != prev_pos:
|
| 62 |
+
selected.add(min_h)
|
| 63 |
+
prev_pos = min_pos
|
| 64 |
+
return selected
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def fingerprint(words: list[str], k: int, w: int) -> set[int]:
|
| 68 |
+
"""The full fingerprint of a document: k-grams, then winnowed."""
|
| 69 |
+
return winnow(kgram_hashes(words, k), w)
|
similarity/index_writer.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write path into the internal corpus (plan.md §7.4).
|
| 2 |
+
|
| 3 |
+
Gated on `SIM_INDEX_UPLOADS`, which ships `false`: indexing a user's paper so
|
| 4 |
+
it can be matched against other users' papers is a consent decision (plan.md
|
| 5 |
+
§16 risk 6), not a default. The index itself lives in Mongo collections
|
| 6 |
+
`corpus_fingerprints` and `corpus_texts` (plan.md §6.2); the multikey index on
|
| 7 |
+
`fingerprints` makes the read path a single indexed query.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
|
| 14 |
+
from similarity.config import settings
|
| 15 |
+
from similarity.fingerprint import fingerprint
|
| 16 |
+
from similarity.normalize import normalize
|
| 17 |
+
|
| 18 |
+
COLL_FINGERPRINTS = "corpus_fingerprints"
|
| 19 |
+
COLL_TEXTS = "corpus_texts"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def ensure_corpus_indexes(mongo) -> None:
|
| 23 |
+
"""Create the multikey index on `fingerprints` at startup.
|
| 24 |
+
|
| 25 |
+
Called once from the orchestrator beside the existing Neo4j index
|
| 26 |
+
creation - never from the per-query hot path.
|
| 27 |
+
"""
|
| 28 |
+
db = mongo._get_db()
|
| 29 |
+
await getattr(db, COLL_FINGERPRINTS).create_index([("fingerprints", 1)])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def index_document(
|
| 33 |
+
mongo,
|
| 34 |
+
*,
|
| 35 |
+
doc_id: str,
|
| 36 |
+
job_id: str,
|
| 37 |
+
title: str,
|
| 38 |
+
owner_email: str,
|
| 39 |
+
text: str,
|
| 40 |
+
k: int | None = None,
|
| 41 |
+
w: int | None = None,
|
| 42 |
+
) -> bool:
|
| 43 |
+
"""Normalize + fingerprint `text` and insert it into the corpus.
|
| 44 |
+
|
| 45 |
+
Returns False when indexing is disabled (the default) or the document is
|
| 46 |
+
too short to fingerprint, True when the rows were written.
|
| 47 |
+
"""
|
| 48 |
+
if not settings.index_uploads:
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
k = k or settings.kgram_size
|
| 52 |
+
w = w or settings.window_size
|
| 53 |
+
nd = normalize(text)
|
| 54 |
+
fps = fingerprint(nd.words, k, w)
|
| 55 |
+
if not fps:
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
db = mongo._get_db()
|
| 59 |
+
await getattr(db, COLL_FINGERPRINTS).insert_one(
|
| 60 |
+
{
|
| 61 |
+
"doc_id": doc_id,
|
| 62 |
+
"job_id": job_id,
|
| 63 |
+
"title": title,
|
| 64 |
+
"owner_email": owner_email,
|
| 65 |
+
"word_count": len(nd.words),
|
| 66 |
+
"fingerprints": list(fps),
|
| 67 |
+
"created_at": datetime.utcnow(),
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
await getattr(db, COLL_TEXTS).insert_one(
|
| 71 |
+
{
|
| 72 |
+
"doc_id": doc_id,
|
| 73 |
+
"normalized_text": " ".join(nd.words),
|
| 74 |
+
"offset_map": {"starts": nd.starts, "ends": nd.ends},
|
| 75 |
+
}
|
| 76 |
+
)
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def purge_corpus_for_job(mongo, *, job_id: str, doc_id: str | None) -> None:
|
| 81 |
+
"""Remove every corpus row belonging to a deleted job (plan.md §16 risk 6).
|
| 82 |
+
|
| 83 |
+
Called from `DELETE /jobs/{job_id}`. Fingerprints are keyed by both
|
| 84 |
+
job_id and doc_id; texts only by doc_id - delete on both axes.
|
| 85 |
+
"""
|
| 86 |
+
db = mongo._get_db()
|
| 87 |
+
fp_filter: dict = {}
|
| 88 |
+
if job_id and doc_id:
|
| 89 |
+
fp_filter = {"$or": [{"job_id": job_id}, {"doc_id": doc_id}]}
|
| 90 |
+
elif job_id:
|
| 91 |
+
fp_filter = {"job_id": job_id}
|
| 92 |
+
elif doc_id:
|
| 93 |
+
fp_filter = {"doc_id": doc_id}
|
| 94 |
+
else:
|
| 95 |
+
return
|
| 96 |
+
await getattr(db, COLL_FINGERPRINTS).delete_many(fp_filter)
|
| 97 |
+
if doc_id:
|
| 98 |
+
await getattr(db, COLL_TEXTS).delete_many({"doc_id": doc_id})
|
similarity/matcher.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verification: fingerprint seeds -> exact, extended match spans.
|
| 2 |
+
|
| 3 |
+
Stage 2 of the two-stage design. Stage 1 (remote retrieval) only proposes
|
| 4 |
+
candidates; this module proves a match locally with exact word and character
|
| 5 |
+
offsets. A seed found via fingerprint intersection is extended forward and
|
| 6 |
+
backward while consecutive words are identical, and overlapping seeds from the
|
| 7 |
+
same source are merged into one maximal span.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
from .fingerprint import fingerprint, kgram_hashes
|
| 15 |
+
from .normalize import NormalizedDoc
|
| 16 |
+
|
| 17 |
+
_EXCERPT_LIMIT = 300
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class MatchedSpan:
|
| 22 |
+
"""A verified verbatim overlap between the document and one source."""
|
| 23 |
+
|
| 24 |
+
doc_start_word: int # inclusive
|
| 25 |
+
doc_end_word: int # exclusive
|
| 26 |
+
doc_char_start: int # offset into the ORIGINAL document text
|
| 27 |
+
doc_char_end: int
|
| 28 |
+
word_count: int
|
| 29 |
+
excerpt: str # <= 300 chars
|
| 30 |
+
source_excerpt: str # matching text in the source
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _excerpt(text: str, limit: int = _EXCERPT_LIMIT) -> str:
|
| 34 |
+
collapsed = " ".join(text.split())
|
| 35 |
+
return collapsed[:limit] + ("..." if len(collapsed) > limit else "")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _position_index(words: list[str], k: int) -> dict[int, list[int]]:
|
| 39 |
+
"""hash -> list of k-gram start-word indices (only for unique-ish lookup)."""
|
| 40 |
+
index: dict[int, list[int]] = {}
|
| 41 |
+
for i, h in enumerate(kgram_hashes(words, k)):
|
| 42 |
+
index.setdefault(h, []).append(i)
|
| 43 |
+
return index
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def match_documents(
|
| 47 |
+
nd_doc: NormalizedDoc,
|
| 48 |
+
nd_src: NormalizedDoc,
|
| 49 |
+
*,
|
| 50 |
+
k: int,
|
| 51 |
+
w: int,
|
| 52 |
+
min_match: int,
|
| 53 |
+
) -> list[MatchedSpan]:
|
| 54 |
+
"""Return all verified verbatim spans of the doc inside the source."""
|
| 55 |
+
if len(nd_doc.words) < k or len(nd_src.words) < k:
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
fp_doc = fingerprint(nd_doc.words, k, w)
|
| 59 |
+
fp_src = fingerprint(nd_src.words, k, w)
|
| 60 |
+
common = fp_doc & fp_src
|
| 61 |
+
if not common:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
doc_pos = _position_index(nd_doc.words, k)
|
| 65 |
+
src_pos = _position_index(nd_src.words, k)
|
| 66 |
+
|
| 67 |
+
raw: list[tuple[int, int]] = [] # (doc_start, doc_end) candidates
|
| 68 |
+
for h in common:
|
| 69 |
+
for dp in doc_pos.get(h, []):
|
| 70 |
+
for sp in src_pos.get(h, []):
|
| 71 |
+
start_d, start_s = _extend_backward(nd_doc, nd_src, dp, sp, k)
|
| 72 |
+
end_d, _ = _extend_forward(nd_doc, nd_src, start_d, start_s, k)
|
| 73 |
+
raw.append((start_d, end_d))
|
| 74 |
+
|
| 75 |
+
if not raw:
|
| 76 |
+
return []
|
| 77 |
+
|
| 78 |
+
merged = _merge_spans(raw)
|
| 79 |
+
spans: list[MatchedSpan] = []
|
| 80 |
+
for start_w, end_w in merged:
|
| 81 |
+
count = end_w - start_w
|
| 82 |
+
if count < min_match:
|
| 83 |
+
continue
|
| 84 |
+
cstart, cend = nd_doc.char_span(start_w, end_w)
|
| 85 |
+
s_excerpt = _excerpt(" ".join(nd_src.words[start_w:end_w])) if start_w < len(nd_src.words) else ""
|
| 86 |
+
spans.append(
|
| 87 |
+
MatchedSpan(
|
| 88 |
+
doc_start_word=start_w,
|
| 89 |
+
doc_end_word=end_w,
|
| 90 |
+
doc_char_start=cstart,
|
| 91 |
+
doc_char_end=cend,
|
| 92 |
+
word_count=count,
|
| 93 |
+
excerpt=_excerpt(nd_doc.original_text[cstart:cend]),
|
| 94 |
+
source_excerpt=s_excerpt,
|
| 95 |
+
)
|
| 96 |
+
)
|
| 97 |
+
return spans
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _extend_backward(
|
| 101 |
+
nd_doc: NormalizedDoc, nd_src: NormalizedDoc, dp: int, sp: int, k: int
|
| 102 |
+
) -> tuple[int, int]:
|
| 103 |
+
while dp > 0 and sp > 0 and nd_doc.words[dp - 1].lower() == nd_src.words[sp - 1].lower():
|
| 104 |
+
dp -= 1
|
| 105 |
+
sp -= 1
|
| 106 |
+
return dp, sp
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _extend_forward(
|
| 110 |
+
nd_doc: NormalizedDoc, nd_src: NormalizedDoc, dp: int, sp: int, k: int
|
| 111 |
+
) -> tuple[int, int]:
|
| 112 |
+
"""dp/sp point at a matching k-gram; extend past its k words."""
|
| 113 |
+
end_d = dp + k
|
| 114 |
+
end_s = sp + k
|
| 115 |
+
while (
|
| 116 |
+
end_d < len(nd_doc.words)
|
| 117 |
+
and end_s < len(nd_src.words)
|
| 118 |
+
and nd_doc.words[end_d].lower() == nd_src.words[end_s].lower()
|
| 119 |
+
):
|
| 120 |
+
end_d += 1
|
| 121 |
+
end_s += 1
|
| 122 |
+
return end_d, end_s
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _merge_spans(raw: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
| 126 |
+
"""Merge overlapping word ranges from overlapping seeds."""
|
| 127 |
+
if not raw:
|
| 128 |
+
return []
|
| 129 |
+
ordered = sorted(raw)
|
| 130 |
+
merged: list[tuple[int, int]] = []
|
| 131 |
+
for start, end in ordered:
|
| 132 |
+
if merged and start <= merged[-1][1]:
|
| 133 |
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
| 134 |
+
else:
|
| 135 |
+
merged.append((start, end))
|
| 136 |
+
return merged
|
similarity/normalize.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text normalization with a character-offset map back into the original.
|
| 2 |
+
|
| 3 |
+
The offset map is what makes highlighting land on the right pixels: each word
|
| 4 |
+
carries the char start/end it occupied in the ORIGINAL text, so a matched span
|
| 5 |
+
can be turned into exact character offsets without any re-searching.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
# A "word" is a run of letters/digits (unicode-aware), optionally joined by an
|
| 14 |
+
# apostrophe or hyphen so "state-of-the-art" and "don't" stay one token. The
|
| 15 |
+
# regex only ever matches a contiguous slice of the original string, so the
|
| 16 |
+
# offset map round-trips by construction.
|
| 17 |
+
_WORD_RE = re.compile(r"[^\W_]+(?:['\-\u2019][^\W_]+)*")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class NormalizedDoc:
|
| 22 |
+
"""Tokenized document with char offsets into the original text."""
|
| 23 |
+
|
| 24 |
+
original_text: str
|
| 25 |
+
words: list[str] # tokens, case preserved as in the source
|
| 26 |
+
starts: list[int] # char offset of each token's first char
|
| 27 |
+
ends: list[int] # char offset one past each token's last char
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def word_count(self) -> int:
|
| 31 |
+
return len(self.words)
|
| 32 |
+
|
| 33 |
+
def char_span(self, start_word: int, end_word: int) -> tuple[int, int]:
|
| 34 |
+
"""Char offsets covering words [start_word, end_word) if available."""
|
| 35 |
+
if not self.words:
|
| 36 |
+
return (0, 0)
|
| 37 |
+
start = self.starts[start_word] if start_word < len(self.words) else 0
|
| 38 |
+
end = self.ends[end_word - 1] if 0 < end_word <= len(self.words) else len(self.original_text)
|
| 39 |
+
return (start, end)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def normalize(text: str) -> NormalizedDoc:
|
| 43 |
+
text = text or ""
|
| 44 |
+
words: list[str] = []
|
| 45 |
+
starts: list[int] = []
|
| 46 |
+
ends: list[int] = []
|
| 47 |
+
for m in _WORD_RE.finditer(text):
|
| 48 |
+
words.append(m.group(0))
|
| 49 |
+
starts.append(m.start())
|
| 50 |
+
ends.append(m.end())
|
| 51 |
+
return NormalizedDoc(original_text=text, words=words, starts=starts, ends=ends)
|
similarity/pipeline.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The similarity pipeline: normalize -> exclude -> select -> retrieve ->
|
| 2 |
+
verify -> match -> aggregate -> report (plan.md §4, §8).
|
| 3 |
+
|
| 4 |
+
Everything before retrieval is pure (Phase 0 modules). Retrieval is wrapped in
|
| 5 |
+
a hard `asyncio.wait_for` at `SIM_BUDGET_SECONDS`; on timeout we return
|
| 6 |
+
whatever is verified so far with `status="partial"` and
|
| 7 |
+
`coverage.budget_exhausted=True` — partial evidence, honestly labelled, beats
|
| 8 |
+
a hung job.
|
| 9 |
+
|
| 10 |
+
Provider failures are never swallowed: every provider's `last_error` lands in
|
| 11 |
+
`coverage.providers_failed`. The preprint self-match guard (§16 risk 2) drops
|
| 12 |
+
candidates that are the uploaded paper's own arXiv preprint BEFORE scoring.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
from similarity.aggregate import Match, compute_percentages
|
| 21 |
+
from similarity.config import settings
|
| 22 |
+
from similarity.corpus.base import Candidate, CorpusProvider
|
| 23 |
+
from similarity.exclusions import find_exclusions
|
| 24 |
+
from similarity.matcher import match_documents
|
| 25 |
+
from similarity.normalize import normalize
|
| 26 |
+
from similarity.schema import (
|
| 27 |
+
Bucket,
|
| 28 |
+
Coverage,
|
| 29 |
+
MatchSpan,
|
| 30 |
+
SimilarityReport,
|
| 31 |
+
SourceMatch,
|
| 32 |
+
)
|
| 33 |
+
from similarity.selector import select_phrases
|
| 34 |
+
|
| 35 |
+
_WORD_RE = re.compile(r"[^\W_]+")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _token_overlap(a: str, b: str) -> float:
|
| 39 |
+
ta = set(_WORD_RE.findall((a or "").lower()))
|
| 40 |
+
tb = set(_WORD_RE.findall((b or "").lower()))
|
| 41 |
+
if not ta or not tb:
|
| 42 |
+
return 0.0
|
| 43 |
+
return len(ta & tb) / min(len(ta), len(tb))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _author_overlap(a: list[str], b: list[str]) -> float:
|
| 47 |
+
sa = {x.lower() for x in (a or [])}
|
| 48 |
+
sb = {x.lower() for x in (b or [])}
|
| 49 |
+
if not sa or not sb:
|
| 50 |
+
return 0.0
|
| 51 |
+
return len(sa & sb) / max(len(sa), len(sb))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_preprint_self_match(
|
| 55 |
+
*,
|
| 56 |
+
doc_title: str,
|
| 57 |
+
cand_title: str,
|
| 58 |
+
cand_doi: str | None,
|
| 59 |
+
cand_authors: list[str],
|
| 60 |
+
similarity: int,
|
| 61 |
+
doc_authors: list[str] | None = None,
|
| 62 |
+
doc_doi: str | None = None,
|
| 63 |
+
) -> bool:
|
| 64 |
+
"""plan.md §16 risk 2: drop the paper's own preprint before scoring."""
|
| 65 |
+
if doc_doi and cand_doi and doc_doi.lower() == cand_doi.lower():
|
| 66 |
+
return True
|
| 67 |
+
if _token_overlap(doc_title, cand_title) >= 0.9:
|
| 68 |
+
return True
|
| 69 |
+
if similarity > 60 and _author_overlap(doc_authors or [], cand_authors) >= 0.5:
|
| 70 |
+
return True
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _excerpt(text: str, limit: int = 300) -> str:
|
| 75 |
+
collapsed = " ".join(text.split())
|
| 76 |
+
return collapsed[:limit] + ("..." if len(collapsed) > limit else "")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class SimilarityPipeline:
|
| 80 |
+
"""Coordinates the corpus providers and the pure scoring core."""
|
| 81 |
+
|
| 82 |
+
def __init__(
|
| 83 |
+
self,
|
| 84 |
+
providers: list | None = None,
|
| 85 |
+
*,
|
| 86 |
+
k: int | None = None,
|
| 87 |
+
w: int | None = None,
|
| 88 |
+
min_match: int | None = None,
|
| 89 |
+
max_queries: int | None = None,
|
| 90 |
+
budget_seconds: float | None = None,
|
| 91 |
+
doc_title: str = "",
|
| 92 |
+
doc_doi: str | None = None,
|
| 93 |
+
doc_authors: list[str] | None = None,
|
| 94 |
+
embedder=None,
|
| 95 |
+
):
|
| 96 |
+
self.providers = providers or []
|
| 97 |
+
self.k = k or settings.kgram_size
|
| 98 |
+
self.w = w or settings.window_size
|
| 99 |
+
self.min_match = min_match or settings.min_match_words
|
| 100 |
+
self.max_queries = max_queries or settings.max_queries
|
| 101 |
+
self.budget_seconds = budget_seconds if budget_seconds is not None else settings.budget_seconds
|
| 102 |
+
self.doc_title = doc_title or ""
|
| 103 |
+
self.doc_doi = doc_doi
|
| 104 |
+
self.doc_authors = doc_authors or []
|
| 105 |
+
self.embedder = embedder
|
| 106 |
+
|
| 107 |
+
async def run(self, text: str, *, doc_id: str, keywords: list[str] | None = None) -> SimilarityReport:
|
| 108 |
+
nd = normalize(text)
|
| 109 |
+
exclusions = find_exclusions(nd)
|
| 110 |
+
total = nd.word_count - exclusions.count_words()
|
| 111 |
+
|
| 112 |
+
if total < settings.min_document_words:
|
| 113 |
+
return SimilarityReport(
|
| 114 |
+
overall_percent=0,
|
| 115 |
+
coverage=Coverage(
|
| 116 |
+
total_words_compared=total,
|
| 117 |
+
words_excluded=exclusions.count_words(),
|
| 118 |
+
exclusions_applied=sorted(exclusions.names()),
|
| 119 |
+
providers_queried=[p.name for p in self.providers],
|
| 120 |
+
phrases_queried=0,
|
| 121 |
+
candidates_retrieved=0,
|
| 122 |
+
candidates_verified=0,
|
| 123 |
+
internal_corpus_size=0,
|
| 124 |
+
min_match_words=self.min_match,
|
| 125 |
+
),
|
| 126 |
+
status="unavailable",
|
| 127 |
+
notes=[
|
| 128 |
+
f"Document too short ({total} comparable words < "
|
| 129 |
+
f"{settings.min_document_words}) - no number is rendered."
|
| 130 |
+
],
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
phrases = select_phrases(nd, exclusions, max_queries=self.max_queries)
|
| 134 |
+
coverage = Coverage(
|
| 135 |
+
total_words_compared=total,
|
| 136 |
+
words_excluded=exclusions.count_words(),
|
| 137 |
+
exclusions_applied=sorted(exclusions.names()),
|
| 138 |
+
providers_queried=[p.name for p in self.providers],
|
| 139 |
+
phrases_queried=len(phrases),
|
| 140 |
+
candidates_retrieved=0,
|
| 141 |
+
candidates_verified=0,
|
| 142 |
+
internal_corpus_size=0,
|
| 143 |
+
min_match_words=self.min_match,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
matches, sources, notes, retrieved, verified, spans_by_source, source_providers = await asyncio.wait_for(
|
| 148 |
+
self._retrieve_and_verify(nd, phrases, coverage),
|
| 149 |
+
timeout=self.budget_seconds,
|
| 150 |
+
)
|
| 151 |
+
budget_exhausted = False
|
| 152 |
+
except asyncio.TimeoutError:
|
| 153 |
+
matches, sources, notes, retrieved, verified, spans_by_source, source_providers = [], {}, [], 0, 0, {}, {}
|
| 154 |
+
budget_exhausted = True
|
| 155 |
+
|
| 156 |
+
coverage.candidates_retrieved = retrieved
|
| 157 |
+
coverage.candidates_verified = verified
|
| 158 |
+
coverage.budget_exhausted = budget_exhausted
|
| 159 |
+
|
| 160 |
+
result = compute_percentages(total, matches, min_match_words=self.min_match)
|
| 161 |
+
|
| 162 |
+
source_matches = self._assemble_sources(sources, spans_by_source, source_providers, result.per_source, total)
|
| 163 |
+
|
| 164 |
+
status = "complete"
|
| 165 |
+
if coverage.providers_failed or budget_exhausted:
|
| 166 |
+
status = "partial"
|
| 167 |
+
|
| 168 |
+
report = SimilarityReport(
|
| 169 |
+
overall_percent=result.overall_percent,
|
| 170 |
+
bucket_percents=result.bucket_percents,
|
| 171 |
+
sources=source_matches,
|
| 172 |
+
coverage=coverage,
|
| 173 |
+
status=status,
|
| 174 |
+
notes=notes,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Paraphrase pass is decoration only - it must never move the headline.
|
| 178 |
+
from similarity.semantic import semantic_pass
|
| 179 |
+
report = semantic_pass(report, doc_text=text, embedder=self.embedder)
|
| 180 |
+
|
| 181 |
+
return report
|
| 182 |
+
|
| 183 |
+
async def _retrieve_and_verify(self, nd, phrases, coverage):
|
| 184 |
+
matches: list[Match] = []
|
| 185 |
+
spans_by_source: dict[int, list[MatchSpan]] = {}
|
| 186 |
+
source_candidates: dict[int, Candidate] = {}
|
| 187 |
+
source_providers: dict[int, CorpusProvider] = {}
|
| 188 |
+
notes: list[str] = []
|
| 189 |
+
retrieved = 0
|
| 190 |
+
verified = 0
|
| 191 |
+
dropped_self = 0
|
| 192 |
+
next_source_index = 1
|
| 193 |
+
trust = {Bucket.INTERNAL: 3, Bucket.OPEN_ACCESS: 2, Bucket.PUBLICATION: 1}
|
| 194 |
+
|
| 195 |
+
for provider in self.providers:
|
| 196 |
+
if provider.last_error:
|
| 197 |
+
coverage.providers_failed.append(provider.name)
|
| 198 |
+
|
| 199 |
+
for phrase in phrases:
|
| 200 |
+
for provider in self.providers:
|
| 201 |
+
try:
|
| 202 |
+
candidates = await provider.search_phrase(phrase, limit=10)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
provider.last_error = str(e)
|
| 205 |
+
candidates = []
|
| 206 |
+
retrieved += len(candidates)
|
| 207 |
+
if provider.last_error and provider.name not in coverage.providers_failed:
|
| 208 |
+
coverage.providers_failed.append(provider.name)
|
| 209 |
+
for cand in candidates:
|
| 210 |
+
if not cand.url and cand.bucket is not Bucket.INTERNAL:
|
| 211 |
+
continue # §6.1: a source you cannot open is not evidence
|
| 212 |
+
if is_preprint_self_match(
|
| 213 |
+
doc_title=self.doc_title,
|
| 214 |
+
cand_title=cand.title,
|
| 215 |
+
cand_doi=cand.doi,
|
| 216 |
+
cand_authors=cand.authors,
|
| 217 |
+
similarity=100, # conservative: pre-filter before measuring
|
| 218 |
+
doc_authors=self.doc_authors,
|
| 219 |
+
doc_doi=self.doc_doi,
|
| 220 |
+
):
|
| 221 |
+
dropped_self += 1
|
| 222 |
+
continue
|
| 223 |
+
try:
|
| 224 |
+
text = cand.text or await provider.fetch_text(cand)
|
| 225 |
+
except Exception as e:
|
| 226 |
+
provider.last_error = str(e)
|
| 227 |
+
text = None
|
| 228 |
+
if not text:
|
| 229 |
+
continue
|
| 230 |
+
verified += 1
|
| 231 |
+
nd_src = normalize(text)
|
| 232 |
+
spans = match_documents(nd, nd_src, k=self.k, w=self.w, min_match=self.min_match)
|
| 233 |
+
if not spans:
|
| 234 |
+
continue
|
| 235 |
+
index = next_source_index
|
| 236 |
+
next_source_index += 1
|
| 237 |
+
source_candidates[index] = cand
|
| 238 |
+
source_providers[index] = provider
|
| 239 |
+
bucket = cand.bucket or provider.bucket
|
| 240 |
+
spans_by_source[index] = [
|
| 241 |
+
MatchSpan(
|
| 242 |
+
doc_start_word=s.doc_start_word,
|
| 243 |
+
doc_end_word=s.doc_end_word,
|
| 244 |
+
doc_char_start=s.doc_char_start,
|
| 245 |
+
doc_char_end=s.doc_char_end,
|
| 246 |
+
word_count=s.word_count,
|
| 247 |
+
excerpt=s.excerpt,
|
| 248 |
+
source_excerpt=s.source_excerpt,
|
| 249 |
+
source_index=index,
|
| 250 |
+
)
|
| 251 |
+
for s in spans
|
| 252 |
+
]
|
| 253 |
+
matches.append(
|
| 254 |
+
Match(
|
| 255 |
+
source_index=index,
|
| 256 |
+
bucket=bucket,
|
| 257 |
+
trust=trust.get(bucket, 1),
|
| 258 |
+
doc_start=spans[0].doc_start_word,
|
| 259 |
+
doc_end=max(s.doc_end_word for s in spans),
|
| 260 |
+
)
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
if dropped_self:
|
| 264 |
+
notes.append(f"{dropped_self} probable preprint/self-citation excluded")
|
| 265 |
+
return matches, source_candidates, notes, retrieved, verified, spans_by_source, source_providers
|
| 266 |
+
|
| 267 |
+
def _assemble_sources(self, source_candidates, spans_by_source, source_providers, per_source, total):
|
| 268 |
+
sources: list[SourceMatch] = []
|
| 269 |
+
for index, cand in sorted(source_candidates.items()):
|
| 270 |
+
words = per_source.get(index, 0)
|
| 271 |
+
provider = source_providers.get(index)
|
| 272 |
+
build = getattr(provider, "build_source_match", None)
|
| 273 |
+
if build is not None:
|
| 274 |
+
# Internal corpus: the privacy boundary is enforced HERE at
|
| 275 |
+
# construction (plan.md §7.4) - neutral label, no URL, no
|
| 276 |
+
# authors, source_excerpt stripped from the spans.
|
| 277 |
+
sources.append(
|
| 278 |
+
build(
|
| 279 |
+
cand,
|
| 280 |
+
source_index=index,
|
| 281 |
+
matched_words=words,
|
| 282 |
+
percent=round(100 * words / total) if total else 0,
|
| 283 |
+
spans=spans_by_source.get(index, []),
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
continue
|
| 287 |
+
sources.append(
|
| 288 |
+
SourceMatch(
|
| 289 |
+
source_index=index,
|
| 290 |
+
bucket=cand.bucket or Bucket.OPEN_ACCESS,
|
| 291 |
+
title=cand.title,
|
| 292 |
+
url=cand.url,
|
| 293 |
+
doi=cand.doi,
|
| 294 |
+
authors=cand.authors,
|
| 295 |
+
year=cand.year,
|
| 296 |
+
provider=cand.provider or (provider.name if provider else ""),
|
| 297 |
+
display_label=cand.url,
|
| 298 |
+
matched_words=words,
|
| 299 |
+
percent=round(100 * words / total) if total else 0,
|
| 300 |
+
spans=spans_by_source.get(index, []),
|
| 301 |
+
)
|
| 302 |
+
)
|
| 303 |
+
return sources
|
similarity/schema.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for the corpus-similarity report.
|
| 2 |
+
|
| 3 |
+
The field names here are the contract: the pipeline emits them, the API serves
|
| 4 |
+
them, and the frontend renders them. Later phases depend on them, so changes
|
| 5 |
+
ripple - keep them in lock-step with plan.md §6.1.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Literal
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Bucket(str, Enum):
|
| 18 |
+
OPEN_ACCESS = "open_access" # CORE, arXiv - the "Internet Sources" analogue
|
| 19 |
+
PUBLICATION = "publication" # Crossref/DOI-bearing, publisher-hosted
|
| 20 |
+
INTERNAL = "internal" # prior CitationEdge uploads
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class MatchSpan(BaseModel):
|
| 24 |
+
doc_start_word: int # inclusive, index into post-exclusion word list
|
| 25 |
+
doc_end_word: int # exclusive
|
| 26 |
+
doc_char_start: int # offset into ORIGINAL text - drives highlighting
|
| 27 |
+
doc_char_end: int
|
| 28 |
+
word_count: int
|
| 29 |
+
excerpt: str = "" # <= 300 chars, for display
|
| 30 |
+
source_excerpt: str = "" # the matching text in the source
|
| 31 |
+
source_index: int # 1-based, matches the numbered UI chips
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SourceMatch(BaseModel):
|
| 35 |
+
source_index: int
|
| 36 |
+
bucket: Bucket
|
| 37 |
+
title: str
|
| 38 |
+
url: str # ALWAYS resolvable - a source you cannot open
|
| 39 |
+
# is not evidence
|
| 40 |
+
doi: str | None = None
|
| 41 |
+
authors: list[str] = Field(default_factory=list)
|
| 42 |
+
year: int | None = None
|
| 43 |
+
provider: str # "CORE" | "arXiv" | "Crossref" | "OpenAlex" | "CitationEdge Corpus"
|
| 44 |
+
display_label: str
|
| 45 |
+
matched_words: int # attributed words only (plan.md §4.3)
|
| 46 |
+
percent: int # matched_words / total_words * 100, rounded
|
| 47 |
+
spans: list[MatchSpan] = Field(default_factory=list)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ParaphraseFlag(BaseModel):
|
| 51 |
+
doc_excerpt: str
|
| 52 |
+
source_excerpt: str
|
| 53 |
+
source_index: int
|
| 54 |
+
cosine: float
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class Coverage(BaseModel):
|
| 58 |
+
"""Mandatory disclosure - the denominator behind the number."""
|
| 59 |
+
|
| 60 |
+
total_words_compared: int
|
| 61 |
+
words_excluded: int
|
| 62 |
+
exclusions_applied: list[str] = Field(default_factory=list)
|
| 63 |
+
providers_queried: list[str] = Field(default_factory=list)
|
| 64 |
+
providers_failed: list[str] = Field(default_factory=list) # a 429 must be visible
|
| 65 |
+
phrases_queried: int
|
| 66 |
+
candidates_retrieved: int
|
| 67 |
+
candidates_verified: int
|
| 68 |
+
internal_corpus_size: int
|
| 69 |
+
min_match_words: int
|
| 70 |
+
budget_exhausted: bool = False
|
| 71 |
+
checked_at: datetime = Field(default_factory=datetime.utcnow)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SimilarityReport(BaseModel):
|
| 75 |
+
overall_percent: int
|
| 76 |
+
bucket_percents: dict[Bucket, int] = Field(default_factory=dict)
|
| 77 |
+
sources: list[SourceMatch] = Field(default_factory=list)
|
| 78 |
+
paraphrase_flags: list[ParaphraseFlag] = Field(default_factory=list)
|
| 79 |
+
coverage: Coverage
|
| 80 |
+
status: Literal["complete", "partial", "unavailable"]
|
| 81 |
+
notes: list[str] = Field(default_factory=list)
|
| 82 |
+
processing_time_ms: float = 0.0
|
similarity/selector.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Selection of the distinctive phrase queries sent to CORE in stage 1.
|
| 2 |
+
|
| 3 |
+
Plan.md §3: we cannot query every phrase in a paper, so we pick the most
|
| 4 |
+
*distinctive* ones - rare-in-this-document words are usually rare globally
|
| 5 |
+
too - while spreading the selection across sections so we don't query thirty
|
| 6 |
+
phrases from the introduction and none from the methods. Boilerplate, quoted
|
| 7 |
+
text, and all-stopword phrases never get selected.
|
| 8 |
+
|
| 9 |
+
The STOPWORDS / FILLER_TOKENS lists are imported from paper_recommender.py,
|
| 10 |
+
never forked - a phrase-selection rule and the recommender must agree on what
|
| 11 |
+
"generic" means.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import math
|
| 17 |
+
|
| 18 |
+
from services.paper_recommender import FILLER_TOKENS, STOPWORDS
|
| 19 |
+
|
| 20 |
+
from .exclusions import Exclusions
|
| 21 |
+
from .normalize import NormalizedDoc
|
| 22 |
+
|
| 23 |
+
# Normalized (lowercased) boilerplate prefixes that carry no similarity signal.
|
| 24 |
+
BOILERPLATE_PREFIXES: tuple[str, ...] = (
|
| 25 |
+
"in this paper we propose",
|
| 26 |
+
"in this paper, we propose",
|
| 27 |
+
"in this paper we present",
|
| 28 |
+
"in this study we",
|
| 29 |
+
"we present a novel",
|
| 30 |
+
"the rest of this paper is organized as follows",
|
| 31 |
+
"this paper is organized as follows",
|
| 32 |
+
"the remainder of this paper is organized as follows",
|
| 33 |
+
"in recent years, ",
|
| 34 |
+
"section x of this paper",
|
| 35 |
+
"the rest of the paper is organized as follows",
|
| 36 |
+
"we then discuss experimental findings",
|
| 37 |
+
"the contributions of this paper are",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Default phrase length sent to `fullText:"..."` - inside the 8-12 word band.
|
| 41 |
+
DEFAULT_PHRASE_WORDS = 10
|
| 42 |
+
_DEFAULT_BUCKETS = 6
|
| 43 |
+
|
| 44 |
+
_NOISE = STOPWORDS | FILLER_TOKENS
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _is_boilerplate(phrase: str) -> bool:
|
| 48 |
+
lowered = phrase.lower()
|
| 49 |
+
for prefix in BOILERPLATE_PREFIXES:
|
| 50 |
+
if lowered.startswith(prefix):
|
| 51 |
+
return True
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def select_phrases(
|
| 56 |
+
nd: NormalizedDoc,
|
| 57 |
+
exclusions: Exclusions,
|
| 58 |
+
max_queries: int = 24,
|
| 59 |
+
phrase_words: int = DEFAULT_PHRASE_WORDS,
|
| 60 |
+
num_buckets: int = _DEFAULT_BUCKETS,
|
| 61 |
+
) -> list[str]:
|
| 62 |
+
"""Return up to `max_queries` distinctive, stratified query phrases."""
|
| 63 |
+
words = nd.words
|
| 64 |
+
if len(words) < phrase_words:
|
| 65 |
+
return []
|
| 66 |
+
|
| 67 |
+
freq: dict[str, int] = {}
|
| 68 |
+
for w in words:
|
| 69 |
+
low = w.lower()
|
| 70 |
+
if low in _NOISE:
|
| 71 |
+
continue
|
| 72 |
+
freq[low] = freq.get(low, 0) + 1
|
| 73 |
+
|
| 74 |
+
candidates: list[tuple[float, int, str]] = []
|
| 75 |
+
last_start = len(words) - phrase_words
|
| 76 |
+
for i in range(last_start + 1):
|
| 77 |
+
if exclusions.covers(i) or exclusions.covers(i + phrase_words - 1):
|
| 78 |
+
continue
|
| 79 |
+
window = words[i:i + phrase_words]
|
| 80 |
+
phrase = " ".join(window)
|
| 81 |
+
if _is_boilerplate(phrase):
|
| 82 |
+
continue
|
| 83 |
+
lowered = [w.lower() for w in window]
|
| 84 |
+
content = [t for t in lowered if t not in _NOISE]
|
| 85 |
+
if not content or len(content) < phrase_words // 2:
|
| 86 |
+
continue
|
| 87 |
+
# mean inverse token frequency within the document
|
| 88 |
+
score = sum(1.0 / (1.0 + freq.get(t, 0)) for t in content) / len(content)
|
| 89 |
+
candidates.append((score, i, phrase))
|
| 90 |
+
|
| 91 |
+
if not candidates:
|
| 92 |
+
return []
|
| 93 |
+
|
| 94 |
+
# Stratify: chunk candidates by start position, keep the best of each.
|
| 95 |
+
total = len(candidates)
|
| 96 |
+
buckets: list[list[tuple[float, int, str]]] = [[] for _ in range(num_buckets)]
|
| 97 |
+
for cand in candidates:
|
| 98 |
+
_, pos, _ = cand
|
| 99 |
+
idx = min(num_buckets - 1, pos * num_buckets // max(1, total))
|
| 100 |
+
buckets[idx].append(cand)
|
| 101 |
+
|
| 102 |
+
per_bucket = math.ceil(max_queries / num_buckets)
|
| 103 |
+
picked: list[tuple[float, int, str]] = []
|
| 104 |
+
for bucket in buckets:
|
| 105 |
+
bucket.sort(reverse=True) # by (score, pos, phrase)
|
| 106 |
+
picked.extend(bucket[:per_bucket])
|
| 107 |
+
|
| 108 |
+
picked.sort(reverse=True)
|
| 109 |
+
return [phrase for _, _, phrase in picked[:max_queries]]
|
similarity/semantic.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paraphrase pass (plan.md §4, prompt Phase 3).
|
| 2 |
+
|
| 3 |
+
Verbatin matching decides the headline number. The paraphrase pass is a
|
| 4 |
+
human-review aid only: it embeds each matched span's document excerpt against
|
| 5 |
+
the source excerpt and, when the cosine similarity is suspiciously high, emits
|
| 6 |
+
a `ParaphraseFlag` for the frontend to show as "similar wording / possible
|
| 7 |
+
paraphrase - verify". It NEVER touches `overall_percent` or
|
| 8 |
+
`bucket_percents`, so a fuzzy embedding can never inflate the corpus claim.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from similarity.config import settings
|
| 14 |
+
from similarity.schema import ParaphraseFlag, SimilarityReport
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def semantic_pass(
|
| 18 |
+
report: SimilarityReport,
|
| 19 |
+
*,
|
| 20 |
+
doc_text: str | None = None,
|
| 21 |
+
embedder=None,
|
| 22 |
+
) -> SimilarityReport:
|
| 23 |
+
"""Return a copy of `report` with `paraphrase_flags` populated.
|
| 24 |
+
|
| 25 |
+
The pass only runs when enabled AND an embedder is supplied - the embedder
|
| 26 |
+
is heavy (SciBERT) and is constructed by the caller (the agent wiring),
|
| 27 |
+
never implicitly. Without one the report is returned unchanged.
|
| 28 |
+
"""
|
| 29 |
+
if not settings.enable_paraphrase or embedder is None:
|
| 30 |
+
return report
|
| 31 |
+
|
| 32 |
+
flags: list[ParaphraseFlag] = []
|
| 33 |
+
for source in report.sources:
|
| 34 |
+
for span in source.spans:
|
| 35 |
+
doc_excerpt = span.excerpt or (doc_text or "")[:300]
|
| 36 |
+
if not doc_excerpt or not span.source_excerpt:
|
| 37 |
+
continue
|
| 38 |
+
try:
|
| 39 |
+
dv = embedder.embed(doc_excerpt)
|
| 40 |
+
sv = embedder.embed(span.source_excerpt)
|
| 41 |
+
cosine = embedder.cosine_similarity(dv, sv)
|
| 42 |
+
except Exception:
|
| 43 |
+
continue # one bad span must not kill the whole report
|
| 44 |
+
if cosine >= settings.paraphrase_threshold:
|
| 45 |
+
flags.append(
|
| 46 |
+
ParaphraseFlag(
|
| 47 |
+
doc_excerpt=doc_excerpt,
|
| 48 |
+
source_excerpt=span.source_excerpt,
|
| 49 |
+
source_index=span.source_index,
|
| 50 |
+
cosine=round(cosine, 4),
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
return report.model_copy(update={"paraphrase_flags": flags})
|
tests/similarity/conftest.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for similarity Phase 1 tests.
|
| 2 |
+
|
| 3 |
+
A tiny in-memory Mongo stand-in so corpus write/read/purge paths run without
|
| 4 |
+
a real database, mirroring how backend/tests/conftest.py keeps heavy
|
| 5 |
+
dependencies out of the tests.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from similarity.corpus.internal import InternalCorpusProvider
|
| 13 |
+
from similarity.index_writer import COLL_FINGERPRINTS, COLL_TEXTS
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class _FakeCursor:
|
| 17 |
+
def __init__(self, docs, limit):
|
| 18 |
+
self._docs = docs
|
| 19 |
+
self._limit = limit
|
| 20 |
+
|
| 21 |
+
def limit(self, n):
|
| 22 |
+
return _FakeCursor(self._docs, n)
|
| 23 |
+
|
| 24 |
+
async def to_list(self, length=None):
|
| 25 |
+
return list(self._docs[: self._limit])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class _FakeCollection:
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self._docs: list[dict] = []
|
| 31 |
+
self._indexes: list[list] = []
|
| 32 |
+
|
| 33 |
+
async def create_index(self, keys, **kwargs):
|
| 34 |
+
self._indexes.append(keys)
|
| 35 |
+
|
| 36 |
+
async def insert_one(self, doc):
|
| 37 |
+
self._docs.append(dict(doc))
|
| 38 |
+
return {"inserted_id": "fake"}
|
| 39 |
+
|
| 40 |
+
async def delete_many(self, query):
|
| 41 |
+
kept = []
|
| 42 |
+
for d in self._docs:
|
| 43 |
+
if self._matches(d, query):
|
| 44 |
+
continue
|
| 45 |
+
kept.append(d)
|
| 46 |
+
removed = len(self._docs) - len(kept)
|
| 47 |
+
self._docs = kept
|
| 48 |
+
return {"deleted_count": removed}
|
| 49 |
+
|
| 50 |
+
async def find_one(self, query):
|
| 51 |
+
for d in self._docs:
|
| 52 |
+
if self._matches(d, query):
|
| 53 |
+
return dict(d)
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
def find(self, query, projection=None):
|
| 57 |
+
matched = [dict(d) for d in self._docs if self._matches(d, query)]
|
| 58 |
+
if projection and projection.get("_id") == 0:
|
| 59 |
+
for d in matched:
|
| 60 |
+
d.pop("_id", None)
|
| 61 |
+
return _FakeCursor(matched, limit=len(matched))
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def _matches(doc, query):
|
| 65 |
+
for key, cond in query.items():
|
| 66 |
+
if key == "$or":
|
| 67 |
+
if not any(_FakeCollection._matches(doc, sub) for sub in cond):
|
| 68 |
+
return False
|
| 69 |
+
continue
|
| 70 |
+
value = doc.get(key)
|
| 71 |
+
if isinstance(cond, dict):
|
| 72 |
+
if "$in" in cond:
|
| 73 |
+
if not value or not any(h in value for h in cond["$in"]):
|
| 74 |
+
return False
|
| 75 |
+
elif "$ne" in cond and value == cond["$ne"]:
|
| 76 |
+
return False
|
| 77 |
+
elif value != cond:
|
| 78 |
+
return False
|
| 79 |
+
return True
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class FakeMongo:
|
| 83 |
+
"""MongoService-shaped object; tests the collections the corpus uses."""
|
| 84 |
+
|
| 85 |
+
def __init__(self):
|
| 86 |
+
self._collections = {COLL_FINGERPRINTS: _FakeCollection(), COLL_TEXTS: _FakeCollection()}
|
| 87 |
+
|
| 88 |
+
def _get_db(self):
|
| 89 |
+
return self
|
| 90 |
+
|
| 91 |
+
def __getitem__(self, name):
|
| 92 |
+
return self._collections[name]
|
| 93 |
+
|
| 94 |
+
# db.collection attribute access
|
| 95 |
+
@property
|
| 96 |
+
def corpus_fingerprints(self):
|
| 97 |
+
return self._collections[COLL_FINGERPRINTS]
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def corpus_texts(self):
|
| 101 |
+
return self._collections[COLL_TEXTS]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@pytest.fixture
|
| 105 |
+
def fake_mongo():
|
| 106 |
+
return FakeMongo()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@pytest.fixture
|
| 110 |
+
def internal_provider(fake_mongo):
|
| 111 |
+
def _make(*, doc_id="doc_new", job_id=None, k=5, w=4):
|
| 112 |
+
return InternalCorpusProvider(
|
| 113 |
+
fake_mongo, doc_id=doc_id, job_id=job_id, k=k, w=w
|
| 114 |
+
)
|
| 115 |
+
return _make
|
tests/similarity/test_aggregate.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregation tests - the scoring spec from plan.md §4, case by case.
|
| 2 |
+
|
| 3 |
+
The single invariant that must never break: a word is counted once no matter
|
| 4 |
+
how many sources contain it. Every other rule (attribution, buckets, minimum
|
| 5 |
+
length) is layered on top of that bitmap.
|
| 6 |
+
"""
|
| 7 |
+
from similarity.aggregate import Match, compute_percentages
|
| 8 |
+
from similarity.schema import Bucket
|
| 9 |
+
|
| 10 |
+
INTERNAL = Bucket.INTERNAL
|
| 11 |
+
OPEN = Bucket.OPEN_ACCESS
|
| 12 |
+
PUB = Bucket.PUBLICATION
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_identical_document_scores_100():
|
| 16 |
+
# a single match covering every word -> 100%
|
| 17 |
+
total = 10
|
| 18 |
+
matches = [Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=10)]
|
| 19 |
+
result = compute_percentages(total, matches)
|
| 20 |
+
assert result.overall_percent == 100
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_disjoint_text_scores_0():
|
| 24 |
+
total = 10
|
| 25 |
+
result = compute_percentages(total, [])
|
| 26 |
+
assert result.overall_percent == 0
|
| 27 |
+
assert result.per_source == {}
|
| 28 |
+
assert result.bucket_words == {}
|
| 29 |
+
assert result.bucket_percents == {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_one_span_claimed_by_three_sources_counts_once():
|
| 33 |
+
"""The double-count regression: one span in three sources must not inflate
|
| 34 |
+
the number - a word is covered or it is not."""
|
| 35 |
+
total = 10
|
| 36 |
+
matches = [
|
| 37 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=10),
|
| 38 |
+
Match(source_index=2, bucket=PUB, trust=1, doc_start=0, doc_end=10),
|
| 39 |
+
Match(source_index=3, bucket=INTERNAL, trust=1, doc_start=0, doc_end=10),
|
| 40 |
+
]
|
| 41 |
+
result = compute_percentages(total, matches)
|
| 42 |
+
assert result.overall_percent == 100
|
| 43 |
+
assert sum(result.per_source.values()) == total
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_attribution_goes_to_longest_match():
|
| 47 |
+
total = 10
|
| 48 |
+
matches = [
|
| 49 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=10),
|
| 50 |
+
Match(source_index=2, bucket=PUB, trust=1, doc_start=2, doc_end=5),
|
| 51 |
+
]
|
| 52 |
+
result = compute_percentages(total, matches)
|
| 53 |
+
# the shorter overlapping match owns nothing; its words are attributed to
|
| 54 |
+
# the source with the longer confirmed match
|
| 55 |
+
assert result.per_source == {1: 10}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_ties_break_on_higher_trust():
|
| 59 |
+
total = 10
|
| 60 |
+
matches = [
|
| 61 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=10),
|
| 62 |
+
Match(source_index=2, bucket=INTERNAL, trust=2, doc_start=0, doc_end=10),
|
| 63 |
+
]
|
| 64 |
+
result = compute_percentages(total, matches)
|
| 65 |
+
assert result.per_source == {2: 10}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_bucket_percentages_are_independent_and_may_exceed_headline():
|
| 69 |
+
total = 100
|
| 70 |
+
matches = [
|
| 71 |
+
# both buckets cover the same 30 words -> each bucket 30%, headline 30%
|
| 72 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=30),
|
| 73 |
+
Match(source_index=2, bucket=PUB, trust=1, doc_start=0, doc_end=30),
|
| 74 |
+
]
|
| 75 |
+
result = compute_percentages(total, matches)
|
| 76 |
+
assert result.overall_percent == 30
|
| 77 |
+
assert result.bucket_percents[OPEN] == 30
|
| 78 |
+
assert result.bucket_percents[PUB] == 30
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_bucket_percents_can_sum_above_headline():
|
| 82 |
+
total = 100
|
| 83 |
+
matches = [
|
| 84 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=30),
|
| 85 |
+
Match(source_index=2, bucket=PUB, trust=1, doc_start=30, doc_end=60),
|
| 86 |
+
]
|
| 87 |
+
result = compute_percentages(total, matches)
|
| 88 |
+
assert result.overall_percent == 60
|
| 89 |
+
assert result.bucket_percents[OPEN] == 30
|
| 90 |
+
assert result.bucket_percents[PUB] == 30
|
| 91 |
+
# deliberately NOT 60: buckets are "as if only this bucket existed"
|
| 92 |
+
assert result.bucket_percents[OPEN] + result.bucket_percents[PUB] == 60
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_matches_below_minimum_length_are_excluded():
|
| 96 |
+
"""The min-length floor lives at the matcher boundary, but aggregation must
|
| 97 |
+
be robust to a caller passing short spans anyway - it computes, never
|
| 98 |
+
assumes the input is pre-filtered."""
|
| 99 |
+
total = 100
|
| 100 |
+
matches = [
|
| 101 |
+
Match(source_index=1, bucket=OPEN, trust=1, doc_start=0, doc_end=2),
|
| 102 |
+
]
|
| 103 |
+
result = compute_percentages(total, matches, min_match_words=8)
|
| 104 |
+
assert result.overall_percent == 0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_zero_total_words_returns_zero():
|
| 108 |
+
result = compute_percentages(0, [])
|
| 109 |
+
assert result.overall_percent == 0
|
tests/similarity/test_corpus_providers.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider tests for CORE, arXiv, OpenAlex, Crossref.
|
| 2 |
+
|
| 3 |
+
No network: every request goes through `httpx.MockTransport`. Fixtures cover
|
| 4 |
+
the happy path, 429, 5xx, malformed JSON, empty results, and a candidate
|
| 5 |
+
with no resolvable URL (which must be dropped, plan.md §6.1).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from similarity.corpus.base import Candidate
|
| 16 |
+
from similarity.corpus.core_api import CoreApiProvider
|
| 17 |
+
from similarity.corpus.openalex import OpenAlexProvider
|
| 18 |
+
from similarity.corpus.crossref import CrossrefProvider
|
| 19 |
+
from similarity.corpus.arxiv import ArxivProvider
|
| 20 |
+
from similarity.schema import Bucket
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture(autouse=True)
|
| 24 |
+
def _no_sleep(monkeypatch):
|
| 25 |
+
"""The shared @async_retry and per-call stagger sleep on failure paths;
|
| 26 |
+
collapse them so failure tests run fast without changing behaviour."""
|
| 27 |
+
async def _instant(delay):
|
| 28 |
+
return None
|
| 29 |
+
monkeypatch.setattr("asyncio.sleep", _instant)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _client(handler) -> httpx.AsyncClient:
|
| 33 |
+
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ── CORE ──────────────────────────────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
CORE_WORK = {
|
| 39 |
+
"id": "C1",
|
| 40 |
+
"title": "Quantum rings at equilibrium",
|
| 41 |
+
"downloadUrl": "https://core.ac.uk/download/1.pdf",
|
| 42 |
+
"doi": "10.1007/x",
|
| 43 |
+
"yearPublished": 2021,
|
| 44 |
+
"authors": ["A. Physicist", "B. Theorist"],
|
| 45 |
+
"fullText": "quantum coherent transport through a one dimensional chain",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def core_client():
|
| 51 |
+
def handler(request):
|
| 52 |
+
return httpx.Response(200, json={"results": [CORE_WORK]})
|
| 53 |
+
return _client(handler)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture
|
| 57 |
+
def core_429():
|
| 58 |
+
return _client(lambda req: httpx.Response(429))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture
|
| 62 |
+
def core_500():
|
| 63 |
+
return _client(lambda req: httpx.Response(500))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@pytest.fixture
|
| 67 |
+
def core_malformed():
|
| 68 |
+
return _client(lambda req: httpx.Response(200, text="not json {{{"))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture
|
| 72 |
+
def core_empty():
|
| 73 |
+
return _client(lambda req: httpx.Response(200, json={"results": []}))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def test_core_happy_path(core_client):
|
| 77 |
+
p = CoreApiProvider(api_key="k", client=core_client, max_attempts=1)
|
| 78 |
+
cands = await p.search_phrase("quantum coherent transport", limit=5)
|
| 79 |
+
assert len(cands) == 1
|
| 80 |
+
c = cands[0]
|
| 81 |
+
assert c.candidate_id == "C1"
|
| 82 |
+
assert c.title == "Quantum rings at equilibrium"
|
| 83 |
+
assert c.url == "https://core.ac.uk/download/1.pdf"
|
| 84 |
+
assert c.bucket is Bucket.OPEN_ACCESS
|
| 85 |
+
assert c.provider == "CORE"
|
| 86 |
+
# full text inline => fetch is a single round trip
|
| 87 |
+
assert (await p.fetch_text(c)) == "quantum coherent transport through a one dimensional chain"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def test_core_sends_bearer_when_key_present():
|
| 91 |
+
captured = {}
|
| 92 |
+
|
| 93 |
+
def handler(request):
|
| 94 |
+
captured["auth"] = request.headers.get("Authorization")
|
| 95 |
+
return httpx.Response(200, json={"results": []})
|
| 96 |
+
|
| 97 |
+
client = _client(handler)
|
| 98 |
+
p = CoreApiProvider(api_key="secret", client=client, max_attempts=1)
|
| 99 |
+
await p.search_phrase("some phrase", limit=5)
|
| 100 |
+
assert captured["auth"] == "Bearer secret"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def test_core_no_key_sends_no_auth():
|
| 104 |
+
captured = {}
|
| 105 |
+
|
| 106 |
+
def handler(request):
|
| 107 |
+
captured["auth"] = request.headers.get("Authorization")
|
| 108 |
+
return httpx.Response(200, json={"results": []})
|
| 109 |
+
|
| 110 |
+
client = _client(handler)
|
| 111 |
+
p = CoreApiProvider(api_key="", client=client, max_attempts=1)
|
| 112 |
+
await p.search_phrase("some phrase", limit=5)
|
| 113 |
+
assert captured["auth"] is None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_core_429_records_error_and_returns_empty(core_429):
|
| 117 |
+
p = CoreApiProvider(api_key="k", client=core_429, max_attempts=1)
|
| 118 |
+
assert await p.search_phrase("phrase", limit=5) == []
|
| 119 |
+
assert p.last_error is not None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def test_core_500_records_error_and_returns_empty(core_500):
|
| 123 |
+
p = CoreApiProvider(api_key="k", client=core_500, max_attempts=1)
|
| 124 |
+
assert await p.search_phrase("phrase", limit=5) == []
|
| 125 |
+
assert p.last_error is not None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def test_core_malformed_json_records_error(core_malformed):
|
| 129 |
+
p = CoreApiProvider(api_key="k", client=core_malformed, max_attempts=1)
|
| 130 |
+
assert await p.search_phrase("phrase", limit=5) == []
|
| 131 |
+
assert p.last_error is not None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def test_core_empty_results(core_empty):
|
| 135 |
+
p = CoreApiProvider(api_key="k", client=core_empty, max_attempts=1)
|
| 136 |
+
assert await p.search_phrase("nothing here", limit=5) == []
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def test_core_metadata_only_record_falls_back_to_doi_url():
|
| 140 |
+
def handler(request):
|
| 141 |
+
return httpx.Response(200, json={"results": [{
|
| 142 |
+
"id": "C2", "title": "No download link", "doi": "10.1109/xyz",
|
| 143 |
+
"fullText": "some matched text here",
|
| 144 |
+
}]})
|
| 145 |
+
p = CoreApiProvider(api_key="k", client=_client(handler), max_attempts=1)
|
| 146 |
+
cands = await p.search_phrase("matched text", limit=5)
|
| 147 |
+
assert cands[0].url == "https://doi.org/10.1109/xyz"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_core_candidate_without_url_or_doi_is_dropped():
|
| 151 |
+
def handler(request):
|
| 152 |
+
return httpx.Response(200, json={"results": [{
|
| 153 |
+
"id": "C3", "title": "No url no doi", "fullText": "text",
|
| 154 |
+
}]})
|
| 155 |
+
p = CoreApiProvider(api_key="k", client=_client(handler), max_attempts=1)
|
| 156 |
+
cands = await p.search_phrase("text", limit=5)
|
| 157 |
+
# the candidate keeps an empty url; the pipeline drops it (test here that
|
| 158 |
+
# it is representable and url-less, matching §6.1 "candidates without a
|
| 159 |
+
# URL are dropped" at the pipeline layer).
|
| 160 |
+
assert len(cands) == 1
|
| 161 |
+
assert cands[0].url == ""
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ── OpenAlex / Crossref ───────────────────────────────────────────────────────
|
| 165 |
+
|
| 166 |
+
OPENALEX_RESULT = {
|
| 167 |
+
"display_name": "Attention is all you need",
|
| 168 |
+
"authorships": [{"author": {"display_name": "A. Vaswani"}}],
|
| 169 |
+
"publication_year": 2017,
|
| 170 |
+
"abstract_inverted_index": {"attention": [0], "is": [1], "all": [2]},
|
| 171 |
+
"cited_by_count": 90000,
|
| 172 |
+
"doi": "https://doi.org/10.1007/x",
|
| 173 |
+
"id": "https://openalex.org/W1",
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@pytest.fixture
|
| 178 |
+
def openalex_client():
|
| 179 |
+
return _client(lambda req: httpx.Response(200, json={"results": [OPENALEX_RESULT]}))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@pytest.fixture
|
| 183 |
+
def crossref_client():
|
| 184 |
+
return _client(lambda req: httpx.Response(200, json={
|
| 185 |
+
"message": {"items": [{
|
| 186 |
+
"type": "journal-article",
|
| 187 |
+
"title": ["A transformer for text"],
|
| 188 |
+
"DOI": "10.1016/j.cam.2020.1",
|
| 189 |
+
"issued": {"date-parts": [[2020]]},
|
| 190 |
+
"URL": "https://doi.org/10.1016/j.cam.2020.1",
|
| 191 |
+
"abstract": "<jats:p>transformers are great for sequences</jats:p>",
|
| 192 |
+
"author": [{"given": "Jane", "family": "Doe"}],
|
| 193 |
+
}]},
|
| 194 |
+
}))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def test_openalex_happy_path(openalex_client):
|
| 198 |
+
p = OpenAlexProvider(client=openalex_client, max_attempts=1)
|
| 199 |
+
cands = await p.search_phrase("attention is all you need", limit=5)
|
| 200 |
+
assert len(cands) == 1
|
| 201 |
+
c = cands[0]
|
| 202 |
+
assert c.bucket is Bucket.PUBLICATION
|
| 203 |
+
assert c.provider == "OpenAlex"
|
| 204 |
+
assert "attention is all" == c.text
|
| 205 |
+
assert c.doi == "10.1007/x"
|
| 206 |
+
assert (await p.fetch_text(c)) == c.text
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
async def test_openalex_429(openalex_client):
|
| 210 |
+
p = OpenAlexProvider(client=_client(lambda req: httpx.Response(429)), max_attempts=1)
|
| 211 |
+
assert await p.search_phrase("x", limit=5) == []
|
| 212 |
+
assert p.last_error is not None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def test_openalex_malformed(openalex_client):
|
| 216 |
+
p = OpenAlexProvider(client=_client(lambda req: httpx.Response(200, text="oops")), max_attempts=1)
|
| 217 |
+
assert await p.search_phrase("x", limit=5) == []
|
| 218 |
+
assert p.last_error is not None
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
async def test_crossref_happy_path(crossref_client):
|
| 222 |
+
p = CrossrefProvider(client=crossref_client, max_attempts=1)
|
| 223 |
+
cands = await p.search_phrase("transformers for text", limit=5)
|
| 224 |
+
assert len(cands) == 1
|
| 225 |
+
c = cands[0]
|
| 226 |
+
assert c.bucket is Bucket.PUBLICATION
|
| 227 |
+
assert c.provider == "Crossref"
|
| 228 |
+
assert c.doi == "10.1016/j.cam.2020.1"
|
| 229 |
+
assert "transformers" in c.text
|
| 230 |
+
assert (await p.fetch_text(c)) == c.text
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def test_crossref_500(crossref_client):
|
| 234 |
+
p = CrossrefProvider(client=_client(lambda req: httpx.Response(500)), max_attempts=1)
|
| 235 |
+
assert await p.search_phrase("x", limit=5) == []
|
| 236 |
+
assert p.last_error is not None
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ── arXiv ─────────────────────────────────────────────────────────────────────
|
| 240 |
+
|
| 241 |
+
ARXIV_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
| 242 |
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
| 243 |
+
<entry>
|
| 244 |
+
<id>http://arxiv.org/abs/2101.00001</id>
|
| 245 |
+
<published>2021-01-01T00:00:00Z</published>
|
| 246 |
+
<title>A test of the arXiv provider end to end</title>
|
| 247 |
+
<author><name>Alice Adams</name></author>
|
| 248 |
+
<summary>This is the abstract text that we search on for now.</summary>
|
| 249 |
+
</entry>
|
| 250 |
+
</feed>
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@pytest.fixture
|
| 255 |
+
def arxiv_client():
|
| 256 |
+
def handler(request):
|
| 257 |
+
return httpx.Response(200, text=ARXIV_XML)
|
| 258 |
+
return _client(handler)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
async def test_arxiv_happy_path(arxiv_client):
|
| 262 |
+
p = ArxivProvider(client=arxiv_client, max_attempts=1)
|
| 263 |
+
cands = await p.search_phrase("test of the arxiv provider", limit=5)
|
| 264 |
+
assert len(cands) == 1
|
| 265 |
+
c = cands[0]
|
| 266 |
+
assert c.bucket is Bucket.OPEN_ACCESS
|
| 267 |
+
assert c.provider == "arXiv"
|
| 268 |
+
assert "arxiv.org" in c.url
|
| 269 |
+
assert c.year == 2021
|
| 270 |
+
assert c.text == "This is the abstract text that we search on for now."
|
| 271 |
+
assert c.authors == ["Alice Adams"]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def test_arxiv_429(arxiv_client):
|
| 275 |
+
p = ArxivProvider(client=_client(lambda req: httpx.Response(429)), max_attempts=1)
|
| 276 |
+
assert await p.search_phrase("x", limit=5) == []
|
| 277 |
+
assert p.last_error is not None
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
async def test_arxiv_malformed_xml(arxiv_client):
|
| 281 |
+
p = ArxivProvider(client=_client(lambda req: httpx.Response(200, text="<<<not xml")), max_attempts=1)
|
| 282 |
+
assert await p.search_phrase("x", limit=5) == []
|
| 283 |
+
assert p.last_error is not None
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def test_arxiv_fetch_text_downloads_and_extracts(arxiv_client, monkeypatch):
|
| 287 |
+
# MockTransport serves the PDF bytes; extraction is mocked to avoid a real
|
| 288 |
+
# pdfplumber parse of arbitrary bytes.
|
| 289 |
+
def pdf_handler(request):
|
| 290 |
+
assert "/pdf/" in str(request.url) and request.url.path.endswith(".pdf")
|
| 291 |
+
return httpx.Response(200, content=b"%PDF-1.4 fake")
|
| 292 |
+
|
| 293 |
+
p = ArxivProvider(
|
| 294 |
+
client=httpx.AsyncClient(transport=httpx.MockTransport(pdf_handler)),
|
| 295 |
+
max_pdf_fetches=5,
|
| 296 |
+
max_attempts=1,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
def fake_extract(path):
|
| 300 |
+
return {"full_text": "the full text of the paper from the pdf", "title": "t"}
|
| 301 |
+
|
| 302 |
+
monkeypatch.setattr("utils.pdf.extract_text_from_pdf", fake_extract)
|
| 303 |
+
c = Candidate(
|
| 304 |
+
candidate_id="A1", title="t", url="http://arxiv.org/abs/2101.00001",
|
| 305 |
+
provider="arXiv", bucket=Bucket.OPEN_ACCESS, text="abstract",
|
| 306 |
+
)
|
| 307 |
+
text = await p.fetch_text(c)
|
| 308 |
+
assert "full text of the paper" in text
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
async def test_arxiv_fetch_budget_caps(arxiv_client, monkeypatch):
|
| 312 |
+
calls = {"n": 0}
|
| 313 |
+
|
| 314 |
+
def fake_extract(path):
|
| 315 |
+
calls["n"] += 1
|
| 316 |
+
return {"full_text": "some extracted text", "title": "t"}
|
| 317 |
+
|
| 318 |
+
monkeypatch.setattr("utils.pdf.extract_text_from_pdf", fake_extract)
|
| 319 |
+
p = ArxivProvider(
|
| 320 |
+
client=arxiv_client, max_pdf_fetches=2, max_attempts=1,
|
| 321 |
+
)
|
| 322 |
+
c = Candidate(
|
| 323 |
+
candidate_id="A1", title="t", url="http://arxiv.org/abs/2101.00001",
|
| 324 |
+
provider="arXiv", bucket=Bucket.OPEN_ACCESS, text="abstract",
|
| 325 |
+
)
|
| 326 |
+
assert await p.fetch_text(c) is not None
|
| 327 |
+
assert await p.fetch_text(c) is not None
|
| 328 |
+
assert await p.fetch_text(c) is None # budget exhausted
|
| 329 |
+
assert calls["n"] == 2
|
tests/similarity/test_exclusions.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exclusion-region tests.
|
| 2 |
+
|
| 3 |
+
The denominator is the word count AFTER exclusions. A paper that is 100%
|
| 4 |
+
bibliography must yield no comparable words - the pipeline reports
|
| 5 |
+
`unavailable`, not 100%. Bibliography, quotes, and the title block are the
|
| 6 |
+
mandatory default exclusions from plan.md §4.1.
|
| 7 |
+
"""
|
| 8 |
+
from similarity.exclusions import find_exclusions
|
| 9 |
+
from similarity.normalize import normalize
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _nd(text):
|
| 13 |
+
return normalize(text)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_bibliography_section_is_excluded():
|
| 17 |
+
text = (
|
| 18 |
+
"This is the introduction with some real content words here. "
|
| 19 |
+
"\n\nReferences\n"
|
| 20 |
+
"[1] Smith, J. A very long reference string about something important. "
|
| 21 |
+
"[2] Jones, K. Another reference that should never be counted as copied."
|
| 22 |
+
)
|
| 23 |
+
nd = _nd(text)
|
| 24 |
+
ex = find_exclusions(nd, exclude_bibliography=True)
|
| 25 |
+
reasons = {name for name, _, _ in ex.ranges}
|
| 26 |
+
assert "bibliography" in reasons
|
| 27 |
+
# every word of the references string is covered by an exclusion
|
| 28 |
+
ref_text = "Smith J A very long reference string about something important"
|
| 29 |
+
ref_words = _nd(ref_text).words
|
| 30 |
+
for w in ref_words:
|
| 31 |
+
idx = nd.words.index(w)
|
| 32 |
+
assert ex.covers(idx), f"word {w!r} should be excluded"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_quoted_text_is_excluded():
|
| 36 |
+
text = (
|
| 37 |
+
'The authors state that "this passage is quoted verbatim from another '
|
| 38 |
+
'work and should not count" and then continue.'
|
| 39 |
+
)
|
| 40 |
+
nd = _nd(text)
|
| 41 |
+
ex = find_exclusions(nd, exclude_quotes=True)
|
| 42 |
+
reasons = {name for name, _, _ in ex.ranges}
|
| 43 |
+
assert "quotes" in reasons
|
| 44 |
+
for i, w in enumerate(nd.words):
|
| 45 |
+
if w in ("this", "passage", "quoted"):
|
| 46 |
+
assert ex.covers(i)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_exclusions_are_optional():
|
| 50 |
+
text = 'He said "the quick brown fox" and moved on.'
|
| 51 |
+
nd = _nd(text)
|
| 52 |
+
with_quotes = find_exclusions(nd, exclude_quotes=True)
|
| 53 |
+
without_quotes = find_exclusions(nd, exclude_quotes=False)
|
| 54 |
+
assert "quotes" in {name for name, _, _ in with_quotes.ranges}
|
| 55 |
+
assert "quotes" not in {name for name, _, _ in without_quotes.ranges}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_covers_returns_false_outside_exclusions():
|
| 59 |
+
text = "Clean body text that is definitely not excluded."
|
| 60 |
+
nd = _nd(text)
|
| 61 |
+
ex = find_exclusions(nd)
|
| 62 |
+
for i, w in enumerate(nd.words):
|
| 63 |
+
if w in ("clean", "body", "definitely"):
|
| 64 |
+
assert not ex.covers(i)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_equations_and_captions_are_excluded():
|
| 68 |
+
text = (
|
| 69 |
+
"Figure 1: The architecture diagram showing layers and weights. "
|
| 70 |
+
"x = alpha plus beta times gamma over delta squared. "
|
| 71 |
+
"Then normal prose resumes again here."
|
| 72 |
+
)
|
| 73 |
+
nd = _nd(text)
|
| 74 |
+
ex = find_exclusions(nd)
|
| 75 |
+
reasons = {name for name, _, _ in ex.ranges}
|
| 76 |
+
assert "captions" in reasons
|
| 77 |
+
assert "equations" in reasons
|
tests/similarity/test_fingerprint.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fingerprint tests, including the winnowing correctness PROPERTY test.
|
| 2 |
+
|
| 3 |
+
The plan's whole recall story rests on one guarantee: if two documents share a
|
| 4 |
+
contiguous run of >= w + k - 1 words, their fingerprint sets MUST intersect.
|
| 5 |
+
That guarantee is tested here directly against randomized inputs - if it ever
|
| 6 |
+
fails, every recall claim in plan.md falls with it.
|
| 7 |
+
"""
|
| 8 |
+
import random
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from similarity.config import settings
|
| 13 |
+
from similarity.fingerprint import fingerprint, kgram_hashes, winnow
|
| 14 |
+
from similarity.normalize import normalize
|
| 15 |
+
|
| 16 |
+
_K = settings.kgram_size
|
| 17 |
+
_W = settings.window_size
|
| 18 |
+
GUARANTEE_WORDS = _K + _W - 1 # 8 words
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _words(text: str) -> list[str]:
|
| 22 |
+
return normalize(text).words
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_kgram_hashes_length():
|
| 26 |
+
hashes = kgram_hashes(_words("a b c d e f g"), _K)
|
| 27 |
+
# 7 words -> 7 - 5 + 1 = 3 k-grams
|
| 28 |
+
assert len(hashes) == 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_kgram_hashes_are_position_independent():
|
| 32 |
+
# same 5-word tuple anywhere -> same hash
|
| 33 |
+
h1 = kgram_hashes(_words("the quick brown fox jumps over the lazy"), _K)
|
| 34 |
+
h2 = kgram_hashes(_words("zzz zzz zzz the quick brown fox jumps end"), _K)
|
| 35 |
+
shared_tuple = kgram_hashes(_words("the quick brown fox jumps over the lazy"), _K)[0]
|
| 36 |
+
assert shared_tuple in h1
|
| 37 |
+
# the tuple "the quick brown fox jumps" occurs at index 3 in the second doc
|
| 38 |
+
assert shared_tuple in h2
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_kgram_hashes_are_case_insensitive():
|
| 42 |
+
h1 = kgram_hashes(_words("The Quick Brown Fox Jumps"), _K)
|
| 43 |
+
h2 = kgram_hashes(_words("the quick brown fox jumps"), _K)
|
| 44 |
+
assert h1 == h2
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_winnow_deduplicates_adjacent_minima():
|
| 48 |
+
hashes = [5, 1, 1, 3, 4, 9, 2, 8, 7]
|
| 49 |
+
selected = winnow(hashes, _W)
|
| 50 |
+
assert 1 in selected # the dominant minimum appears
|
| 51 |
+
assert isinstance(selected, set)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_fingerprint_identical_text_yields_same_set():
|
| 55 |
+
a = _words("the quick brown fox jumps over the lazy dog while the sun sets")
|
| 56 |
+
b = _words("the quick brown fox jumps over the lazy dog while the sun sets")
|
| 57 |
+
assert fingerprint(a, _K, _W) == fingerprint(b, _K, _W)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── The winnowing property test ──────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.parametrize("seed", range(25))
|
| 64 |
+
def test_shared_run_of_w_plus_k_minus_1_words_always_intersects(seed):
|
| 65 |
+
rng = random.Random(seed)
|
| 66 |
+
vocab = [f"word{i}" for i in range(200)]
|
| 67 |
+
|
| 68 |
+
shared_run = [rng.choice(vocab) for _ in range(GUARANTEE_WORDS)]
|
| 69 |
+
|
| 70 |
+
left_pad = [rng.choice(vocab) for _ in range(rng.randint(0, 30))]
|
| 71 |
+
right_pad = [rng.choice(vocab) for _ in range(rng.randint(0, 30))]
|
| 72 |
+
doc_a = left_pad + shared_run + right_pad
|
| 73 |
+
|
| 74 |
+
left_pad_b = [rng.choice(vocab) for _ in range(rng.randint(0, 30))]
|
| 75 |
+
right_pad_b = [rng.choice(vocab) for _ in range(rng.randint(0, 30))]
|
| 76 |
+
doc_b = left_pad_b + shared_run + right_pad_b
|
| 77 |
+
|
| 78 |
+
fp_a = fingerprint(doc_a, _K, _W)
|
| 79 |
+
fp_b = fingerprint(doc_b, _K, _W)
|
| 80 |
+
assert fp_a & fp_b, (
|
| 81 |
+
f"seed={seed}: shared run of {GUARANTEE_WORDS} words produced "
|
| 82 |
+
f"disjoint fingerprints - winnowing guarantee violated"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_unrelated_text_yields_disjoint_fingerprints():
|
| 87 |
+
a = _words("the quick brown fox jumps over the lazy dog on a sunny morning")
|
| 88 |
+
b = _words("quantum entanglement correlates distant particles mysteriously")
|
| 89 |
+
assert not (fingerprint(a, _K, _W) & fingerprint(b, _K, _W))
|
tests/similarity/test_index_writer.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the internal-corpus write path (plan.md §7.4, §16 risk 6).
|
| 2 |
+
|
| 3 |
+
SIM_INDEX_UPLOADS ships false - indexing is a consent decision, so the write
|
| 4 |
+
path must do nothing by default.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from unittest.mock import patch
|
| 10 |
+
|
| 11 |
+
from similarity.index_writer import (
|
| 12 |
+
COLL_FINGERPRINTS,
|
| 13 |
+
COLL_TEXTS,
|
| 14 |
+
ensure_corpus_indexes,
|
| 15 |
+
index_document,
|
| 16 |
+
purge_corpus_for_job,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
DOC_TEXT = (
|
| 20 |
+
"Attention based transformer architectures have become the dominant "
|
| 21 |
+
"approach for sequence modelling tasks in natural language processing "
|
| 22 |
+
"because they capture long range dependencies far more efficiently than "
|
| 23 |
+
"recurrent networks while remaining fully parallelisable during training."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def _index(fake_mongo, **kwargs):
|
| 28 |
+
with patch("similarity.index_writer.settings") as settings:
|
| 29 |
+
settings.index_uploads = True
|
| 30 |
+
settings.kgram_size = 5
|
| 31 |
+
settings.window_size = 4
|
| 32 |
+
return await index_document(
|
| 33 |
+
fake_mongo,
|
| 34 |
+
doc_id="doc_1",
|
| 35 |
+
job_id="job_1",
|
| 36 |
+
title="My paper",
|
| 37 |
+
owner_email="me@example.org",
|
| 38 |
+
text=DOC_TEXT,
|
| 39 |
+
**kwargs,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def test_index_uploads_ships_disabled(fake_mongo):
|
| 44 |
+
with patch("similarity.index_writer.settings") as settings:
|
| 45 |
+
settings.index_uploads = False
|
| 46 |
+
settings.kgram_size = 5
|
| 47 |
+
settings.window_size = 4
|
| 48 |
+
ok = await index_document(
|
| 49 |
+
fake_mongo,
|
| 50 |
+
doc_id="doc_1", job_id="job_1", title="t", owner_email="e", text=DOC_TEXT,
|
| 51 |
+
)
|
| 52 |
+
assert ok is False
|
| 53 |
+
assert fake_mongo.corpus_fingerprints._docs == []
|
| 54 |
+
assert fake_mongo.corpus_texts._docs == []
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_index_document_writes_fingerprint_and_text_rows(fake_mongo):
|
| 58 |
+
ok = await _index(fake_mongo)
|
| 59 |
+
assert ok is True
|
| 60 |
+
|
| 61 |
+
fp_rows = fake_mongo.corpus_fingerprints._docs
|
| 62 |
+
assert len(fp_rows) == 1
|
| 63 |
+
row = fp_rows[0]
|
| 64 |
+
assert row["doc_id"] == "doc_1"
|
| 65 |
+
assert row["job_id"] == "job_1"
|
| 66 |
+
assert row["title"] == "My paper"
|
| 67 |
+
assert row["owner_email"] == "me@example.org"
|
| 68 |
+
assert row["word_count"] > 0
|
| 69 |
+
assert len(row["fingerprints"]) > 0
|
| 70 |
+
assert row["created_at"] is not None
|
| 71 |
+
|
| 72 |
+
text_rows = fake_mongo.corpus_texts._docs
|
| 73 |
+
assert len(text_rows) == 1
|
| 74 |
+
assert text_rows[0]["doc_id"] == "doc_1"
|
| 75 |
+
assert "transformer" in text_rows[0]["normalized_text"]
|
| 76 |
+
assert "starts" in text_rows[0]["offset_map"]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_ensure_corpus_indexes_creates_multikey_on_fingerprints(fake_mongo):
|
| 80 |
+
await ensure_corpus_indexes(fake_mongo)
|
| 81 |
+
assert any("fingerprints" in [f[0] if isinstance(f, tuple) else f[0] for f in keys]
|
| 82 |
+
for keys in fake_mongo.corpus_fingerprints._indexes)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def test_purge_corpus_for_job_removes_rows(fake_mongo):
|
| 86 |
+
await _index(fake_mongo)
|
| 87 |
+
assert fake_mongo.corpus_fingerprints._docs
|
| 88 |
+
|
| 89 |
+
await purge_corpus_for_job(fake_mongo, job_id="job_1", doc_id="doc_1")
|
| 90 |
+
assert fake_mongo.corpus_fingerprints._docs == []
|
| 91 |
+
assert fake_mongo.corpus_texts._docs == []
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def test_purge_by_job_id_only(fake_mongo):
|
| 95 |
+
await _index(fake_mongo)
|
| 96 |
+
await purge_corpus_for_job(fake_mongo, job_id="job_1", doc_id=None)
|
| 97 |
+
assert fake_mongo.corpus_fingerprints._docs == []
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def test_purge_handles_unknown_doc_id(fake_mongo):
|
| 101 |
+
await _index(fake_mongo)
|
| 102 |
+
await purge_corpus_for_job(fake_mongo, job_id="nope", doc_id="nope")
|
| 103 |
+
assert fake_mongo.corpus_fingerprints._docs # untouched
|
tests/similarity/test_matcher.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Span-matching tests.
|
| 2 |
+
|
| 3 |
+
Seeds found via fingerprint intersection must extend into maximal spans with
|
| 4 |
+
exact word and character boundaries, and overlapping seeds must merge into a
|
| 5 |
+
single span rather than two.
|
| 6 |
+
"""
|
| 7 |
+
from similarity.config import settings
|
| 8 |
+
from similarity.matcher import match_documents
|
| 9 |
+
from similarity.normalize import normalize
|
| 10 |
+
|
| 11 |
+
_K = settings.kgram_size
|
| 12 |
+
_W = settings.window_size
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _docs(a, b):
|
| 16 |
+
return normalize(a), normalize(b)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_identical_document_matches_entirety():
|
| 20 |
+
text = (
|
| 21 |
+
"The quick brown fox jumps over the lazy dog while the sun sets in "
|
| 22 |
+
"the west and the birds return to their nests to rest at night."
|
| 23 |
+
)
|
| 24 |
+
nd_a, nd_b = _docs(text, text)
|
| 25 |
+
spans = match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words)
|
| 26 |
+
assert len(spans) == 1
|
| 27 |
+
assert spans[0].word_count == len(nd_a.words)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_disjoint_text_has_no_matches():
|
| 31 |
+
a = "The quick brown fox jumps over the lazy dog on a sunny morning."
|
| 32 |
+
b = "Quantum entanglement correlates distant particles in mysterious ways."
|
| 33 |
+
nd_a, nd_b = _docs(a, b)
|
| 34 |
+
assert match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words) == []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_shared_run_produces_exact_boundaries():
|
| 38 |
+
shared = "this distinctive phrase is copied verbatim from the original"
|
| 39 |
+
a = "introduction text that precedes " + shared + " and trailing prose here"
|
| 40 |
+
b = "different preamble words " + shared + " followed by unrelated content"
|
| 41 |
+
nd_a, nd_b = _docs(a, b)
|
| 42 |
+
|
| 43 |
+
spans = match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words)
|
| 44 |
+
assert len(spans) == 1
|
| 45 |
+
span = spans[0]
|
| 46 |
+
|
| 47 |
+
expected_words = len(shared.split())
|
| 48 |
+
assert span.word_count == expected_words
|
| 49 |
+
|
| 50 |
+
# char offsets must slice back to the shared phrase
|
| 51 |
+
a_shared = nd_a.original_text[span.doc_char_start:span.doc_char_end]
|
| 52 |
+
assert " ".join(a_shared.split()) == shared
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_overlapping_seeds_merge_into_one_span():
|
| 56 |
+
# A long shared run yields many overlapping seeds; they must collapse.
|
| 57 |
+
shared = (
|
| 58 |
+
"one two three four five six seven eight nine ten eleven twelve "
|
| 59 |
+
"thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty"
|
| 60 |
+
)
|
| 61 |
+
a = "prelude words here then " + shared + " postlude words appear"
|
| 62 |
+
b = shared
|
| 63 |
+
nd_a, nd_b = _docs(a, b)
|
| 64 |
+
spans = match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words)
|
| 65 |
+
assert len(spans) == 1
|
| 66 |
+
assert spans[0].word_count == len(shared.split())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_short_shared_runs_are_dropped():
|
| 70 |
+
shared = "a quick phrase" # 3 words, below min_match
|
| 71 |
+
a = "intro words before " + shared + " and more words afterwards"
|
| 72 |
+
b = "totally different lead in " + shared + " then unrelated text here"
|
| 73 |
+
nd_a, nd_b = _docs(a, b)
|
| 74 |
+
spans = match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words)
|
| 75 |
+
assert spans == []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_span_carries_source_and_doc_word_indices():
|
| 79 |
+
shared = "this distinctive phrase is copied verbatim from the original"
|
| 80 |
+
a = "preface " + shared + " ending"
|
| 81 |
+
b = "preamble " + shared + " finale"
|
| 82 |
+
nd_a, nd_b = _docs(a, b)
|
| 83 |
+
spans = match_documents(nd_a, nd_b, k=_K, w=_W, min_match=settings.min_match_words)
|
| 84 |
+
span = spans[0]
|
| 85 |
+
assert span.doc_start_word >= 0
|
| 86 |
+
assert span.doc_end_word <= len(nd_a.words)
|
| 87 |
+
assert span.doc_end_word - span.doc_start_word == span.word_count
|
tests/similarity/test_normalize.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offset-map round-trip tests for similarity.normalize.
|
| 2 |
+
|
| 3 |
+
The offset map is what makes highlighting land on the right pixels: every word
|
| 4 |
+
in the normalized document must slice the ORIGINAL text back to exactly that
|
| 5 |
+
word. If this ever breaks, spans point at the wrong part of the PDF.
|
| 6 |
+
"""
|
| 7 |
+
from similarity.normalize import normalize
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_every_word_round_trips_into_original_text():
|
| 11 |
+
text = (
|
| 12 |
+
"The quick, brown fox! Jumps-over the lazy dog.\n"
|
| 13 |
+
" Second line with numbers 123 and (parens)."
|
| 14 |
+
)
|
| 15 |
+
nd = normalize(text)
|
| 16 |
+
assert len(nd.words) > 0
|
| 17 |
+
for i, w in enumerate(nd.words):
|
| 18 |
+
assert nd.ends[i] > nd.starts[i]
|
| 19 |
+
assert text[nd.starts[i]:nd.ends[i]] == w
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_offsets_are_contiguous_and_monotonic():
|
| 23 |
+
text = "alpha beta gamma delta epsilon"
|
| 24 |
+
nd = normalize(text)
|
| 25 |
+
assert nd.words == ["alpha", "beta", "gamma", "delta", "epsilon"]
|
| 26 |
+
starts = nd.starts
|
| 27 |
+
assert starts == sorted(starts)
|
| 28 |
+
# no overlap: next word starts at or after this word's end
|
| 29 |
+
for i in range(len(nd.words) - 1):
|
| 30 |
+
assert nd.ends[i] <= nd.starts[i + 1]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_punctuation_is_stripped_but_positions_preserved():
|
| 34 |
+
text = "Hello, world!! (parentheses) spaced."
|
| 35 |
+
nd = normalize(text)
|
| 36 |
+
joined = [text[s:e] for s, e in zip(nd.starts, nd.ends)]
|
| 37 |
+
# each token is a pure word, no punctuation
|
| 38 |
+
assert all(tok.isalnum() for tok in joined)
|
| 39 |
+
# original text unchanged
|
| 40 |
+
assert text == "Hello, world!! (parentheses) spaced."
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_empty_text_yields_empty_doc():
|
| 44 |
+
nd = normalize("")
|
| 45 |
+
assert nd.words == []
|
| 46 |
+
assert nd.starts == []
|
| 47 |
+
assert nd.ends == []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_whitespace_only_text_yields_empty_doc():
|
| 51 |
+
nd = normalize(" \n\t ")
|
| 52 |
+
assert nd.words == []
|
tests/similarity/test_pipeline.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline integration tests: normalize -> exclude -> select -> retrieve ->
|
| 2 |
+
verify -> match -> aggregate -> report (plan.md §8).
|
| 3 |
+
|
| 4 |
+
Uses fake providers (no network) so the orchestration, budget, aggregation,
|
| 5 |
+
and status rules are tested against controlled inputs. The semantic/paraphrase
|
| 6 |
+
pass (Phase `semantic.py`) is tested separately and is asserted to never move
|
| 7 |
+
the headline percentage.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from similarity.corpus.base import Candidate
|
| 17 |
+
from similarity.pipeline import SimilarityPipeline
|
| 18 |
+
from similarity.schema import Bucket
|
| 19 |
+
|
| 20 |
+
# ~340+ words so the pipeline is not "unavailable".
|
| 21 |
+
DOC_TEXT = (
|
| 22 |
+
"We study the quantum coherent transport through a one dimensional chain "
|
| 23 |
+
"coupled to external reservoirs. The conductance exhibits remarkable "
|
| 24 |
+
"oscillations as a function of the magnetic flux threading the ring. "
|
| 25 |
+
"We derive an exact expression for the transmission amplitude using the "
|
| 26 |
+
"Landauer formalism and compare the result against the semiclassical "
|
| 27 |
+
"approximation. Our analysis shows that interference between the two arms "
|
| 28 |
+
"of the ring gives rise to periodic resonances whose period is set by the "
|
| 29 |
+
"flux quantum. We also investigate the role of electron electron "
|
| 30 |
+
"interactions and disorder in the chain. For weak coupling the system "
|
| 31 |
+
"behaves as a single channel conductor, whereas strong coupling opens "
|
| 32 |
+
"additional transverse modes. The temperature dependence of the "
|
| 33 |
+
"conductance is governed by thermal averaging over the Fermi function. "
|
| 34 |
+
"We compare our predictions with recent experimental measurements on "
|
| 35 |
+
"semiconductor rings. The agreement is excellent in the coherent regime. "
|
| 36 |
+
"We conclude that the mesoscopic ring is an ideal platform for studying "
|
| 37 |
+
"phase coherent effects and quantum interference at the nanoscale. "
|
| 38 |
+
"These findings have direct implications for the design of quantum "
|
| 39 |
+
"interference devices and for understanding decoherence mechanisms. "
|
| 40 |
+
"The methods we employ combine analytical techniques with numerical "
|
| 41 |
+
"simulation of the scattering matrix. Our numerical results confirm the "
|
| 42 |
+
"analytical predictions over a wide range of parameters. "
|
| 43 |
+
"We finally discuss possible extensions of the model to multi terminal "
|
| 44 |
+
"geometries and spin dependent transport. "
|
| 45 |
+
"A key ingredient of our approach is the coupling between the ring and "
|
| 46 |
+
"the leads which controls the width of the resonances and the visibility "
|
| 47 |
+
"of the interference fringes. We show that the phase coherence length "
|
| 48 |
+
"plays a central role in determining whether the system operates in the "
|
| 49 |
+
"ballistic or the diffusive regime. In the ballistic limit the scattering "
|
| 50 |
+
"is purely elastic and the conductance is quantized in units of the "
|
| 51 |
+
"conductance quantum. In the diffusive limit we recover the familiar "
|
| 52 |
+
"Ohmic behavior with a resistance that scales linearly with the length of "
|
| 53 |
+
"the wire. We discuss the crossover between these two regimes as the "
|
| 54 |
+
"temperature is increased and the phase breaking rate grows. "
|
| 55 |
+
"Our model also accounts for the presence of a weak magnetic impurity "
|
| 56 |
+
"which introduces an asymmetry between the two propagation directions. "
|
| 57 |
+
"The impurity acts as a resonant scatterer whose transmission probability "
|
| 58 |
+
"depends sensitively on the applied flux. This leads to an asymmetric "
|
| 59 |
+
"lineshape in the conductance as a function of the flux and provides a "
|
| 60 |
+
"sensitive probe of the impurity potential. "
|
| 61 |
+
"We compare our findings with a simplified model in which the impurity is "
|
| 62 |
+
"replaced by an effective delta barrier and show that the two descriptions "
|
| 63 |
+
"agree to leading order in the coupling strength. "
|
| 64 |
+
"This work was supported in part by the national research foundation "
|
| 65 |
+
"under grant number 12345 and by a university fellowship. "
|
| 66 |
+
"The numerical simulations were performed on a high performance computing "
|
| 67 |
+
"cluster provided by the department of physics at our institution."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class FakeProvider:
|
| 72 |
+
name = "fake"
|
| 73 |
+
bucket = Bucket.OPEN_ACCESS
|
| 74 |
+
|
| 75 |
+
def __init__(self, phrase_matches: dict[str, list[Candidate]], fail: bool = False,
|
| 76 |
+
always: list[Candidate] | None = None):
|
| 77 |
+
self._phrase_matches = phrase_matches
|
| 78 |
+
self.fail = fail
|
| 79 |
+
self.always = always or []
|
| 80 |
+
self.last_error = None
|
| 81 |
+
self.queries: list[str] = []
|
| 82 |
+
|
| 83 |
+
async def search_phrase(self, phrase, limit):
|
| 84 |
+
self.queries.append(phrase)
|
| 85 |
+
if self.fail:
|
| 86 |
+
self.last_error = "simulated outage"
|
| 87 |
+
return []
|
| 88 |
+
if phrase in self._phrase_matches:
|
| 89 |
+
return self._phrase_matches[phrase]
|
| 90 |
+
return self.always
|
| 91 |
+
|
| 92 |
+
async def fetch_text(self, candidate):
|
| 93 |
+
return candidate.text
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _cand(cid, text, title="A real source", url="https://example.org/paper", bucket=Bucket.OPEN_ACCESS, **kw):
|
| 97 |
+
return Candidate(
|
| 98 |
+
candidate_id=cid, title=title, url=url, provider="fake",
|
| 99 |
+
bucket=bucket, text=text, **kw,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _pipeline(providers):
|
| 104 |
+
return SimilarityPipeline(providers=providers)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def test_unavailable_below_minimum_words():
|
| 108 |
+
pipe = SimilarityPipeline(providers=[FakeProvider({})])
|
| 109 |
+
report = await pipe.run("only a handful of words here", doc_id="d1")
|
| 110 |
+
assert report.status == "unavailable"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
async def test_clean_document_scores_zero():
|
| 114 |
+
"""A document whose phrases match nothing must report 0 and complete."""
|
| 115 |
+
pipe = _pipeline([FakeProvider({})])
|
| 116 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 117 |
+
assert report.status in ("complete", "partial")
|
| 118 |
+
assert report.overall_percent == 0
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def test_full_match_scores_high():
|
| 122 |
+
"""A source containing the whole document => ~100% headline."""
|
| 123 |
+
prov = FakeProvider({}, always=[_cand("s1", DOC_TEXT)])
|
| 124 |
+
pipe = _pipeline([prov])
|
| 125 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 126 |
+
assert report.overall_percent >= 90
|
| 127 |
+
assert any(s.bucket is Bucket.OPEN_ACCESS for s in report.sources)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def test_provider_failure_marks_coverage_but_keeps_job_complete():
|
| 131 |
+
"""A CORE outage must yield partial status + recorded failure, not a crash."""
|
| 132 |
+
failing = FakeProvider({}, fail=True)
|
| 133 |
+
pipe = _pipeline([failing])
|
| 134 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 135 |
+
assert report.status == "partial"
|
| 136 |
+
assert failing.name in report.coverage.providers_failed
|
| 137 |
+
assert report.coverage.candidates_retrieved == 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_provider_raising_exception_does_not_crash_pipeline():
|
| 141 |
+
"""An unexpected exception inside a provider must be caught and recorded,
|
| 142 |
+
exactly like a 429 - the report is partial, never a hard failure."""
|
| 143 |
+
|
| 144 |
+
class ExplodingProvider(FakeProvider):
|
| 145 |
+
async def search_phrase(self, phrase, limit):
|
| 146 |
+
raise RuntimeError("boom")
|
| 147 |
+
|
| 148 |
+
async def fetch_text(self, candidate):
|
| 149 |
+
return candidate.text
|
| 150 |
+
|
| 151 |
+
prov = ExplodingProvider({})
|
| 152 |
+
pipe = _pipeline([prov])
|
| 153 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 154 |
+
assert report.status == "partial"
|
| 155 |
+
assert prov.name in report.coverage.providers_failed
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def test_budget_timeout_yields_partial_with_flag():
|
| 159 |
+
"""If retrieval exceeds the budget, return what we have, flagged."""
|
| 160 |
+
slow = FakeProvider({})
|
| 161 |
+
|
| 162 |
+
async def slow_search(phrase, limit):
|
| 163 |
+
await asyncio.sleep(0.2)
|
| 164 |
+
return []
|
| 165 |
+
|
| 166 |
+
slow.search_phrase = slow_search
|
| 167 |
+
pipe = SimilarityPipeline(providers=[slow], budget_seconds=0.05)
|
| 168 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 169 |
+
assert report.status == "partial"
|
| 170 |
+
assert report.coverage.budget_exhausted is True
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_three_sources_share_one_span_counts_once():
|
| 174 |
+
"""plan.md §13: one span claimed by three sources counts once in the
|
| 175 |
+
headline, but each source still gets its attributed span."""
|
| 176 |
+
text_a = DOC_TEXT
|
| 177 |
+
# three sources that all contain the same span; must count once in headline
|
| 178 |
+
prov = FakeProvider({}, always=[
|
| 179 |
+
_cand("s1", text_a),
|
| 180 |
+
_cand("s2", text_a, bucket=Bucket.PUBLICATION),
|
| 181 |
+
_cand("s3", text_a, bucket=Bucket.INTERNAL),
|
| 182 |
+
])
|
| 183 |
+
pipe = _pipeline([prov])
|
| 184 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 185 |
+
assert report.overall_percent >= 90
|
| 186 |
+
assert len(report.sources) >= 3
|
| 187 |
+
# headline cannot exceed 100 no matter how many sources claim the text
|
| 188 |
+
assert report.overall_percent <= 100
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
async def test_preprint_self_match_candidate_is_dropped():
|
| 192 |
+
"""A candidate that is the uploaded paper's own preprint must be excluded
|
| 193 |
+
and surfaced in coverage.notes, never scored at ~100%."""
|
| 194 |
+
prov = FakeProvider({}, always=[
|
| 195 |
+
_cand(
|
| 196 |
+
"preprint",
|
| 197 |
+
DOC_TEXT,
|
| 198 |
+
title="Quantum coherent transport through a one dimensional chain",
|
| 199 |
+
url="https://arxiv.org/abs/2101.00001",
|
| 200 |
+
),
|
| 201 |
+
])
|
| 202 |
+
pipe = SimilarityPipeline(providers=[prov], doc_title="Quantum coherent transport through a one dimensional chain")
|
| 203 |
+
report = await pipe.run(DOC_TEXT, doc_id="d1")
|
| 204 |
+
assert report.overall_percent == 0
|
| 205 |
+
assert any("self" in n.lower() for n in report.notes)
|
tests/similarity/test_privacy.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Privacy tests for the internal corpus (plan.md §7.4, §16 risk 6).
|
| 2 |
+
|
| 3 |
+
The privacy boundary is the reason the internal bucket exists at all: another
|
| 4 |
+
researcher's uploaded paper is confidential. The report may show that a match
|
| 5 |
+
exists, the excerpt from the USER'S OWN document, a neutral submission label,
|
| 6 |
+
and the percentage - never the other document's title, URL, authors, full
|
| 7 |
+
text, or a link to it.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from unittest.mock import patch
|
| 14 |
+
|
| 15 |
+
from similarity.aggregate import Match, compute_percentages
|
| 16 |
+
from similarity.corpus.internal import InternalCorpusProvider, _submitted_label
|
| 17 |
+
from similarity.index_writer import index_document
|
| 18 |
+
from similarity.matcher import match_documents
|
| 19 |
+
from similarity.normalize import normalize
|
| 20 |
+
from similarity.schema import Bucket, MatchSpan
|
| 21 |
+
|
| 22 |
+
DOC_TEXT = (
|
| 23 |
+
"The quantum coherent transport through a one dimensional chain exhibits "
|
| 24 |
+
"remarkable interference phenomena that depend on the magnetic flux "
|
| 25 |
+
"enclosed by the ring and the coupling strength between the chain and "
|
| 26 |
+
"the reservoirs. We analyze the conductance oscillations using the "
|
| 27 |
+
"Landauer formalism and compare our results with recent experiments."
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def _index(fake_mongo, *, doc_id="doc_a", text=DOC_TEXT, created_at=None):
|
| 32 |
+
with patch("similarity.index_writer.settings") as settings:
|
| 33 |
+
settings.index_uploads = True
|
| 34 |
+
settings.kgram_size = 5
|
| 35 |
+
settings.window_size = 4
|
| 36 |
+
ok = await index_document(
|
| 37 |
+
fake_mongo,
|
| 38 |
+
doc_id=doc_id,
|
| 39 |
+
job_id=f"job_{doc_id}",
|
| 40 |
+
title="Another researcher's paper",
|
| 41 |
+
owner_email="someone@else.org",
|
| 42 |
+
text=text,
|
| 43 |
+
)
|
| 44 |
+
if ok and created_at:
|
| 45 |
+
fake_mongo.corpus_fingerprints._docs[-1]["created_at"] = created_at
|
| 46 |
+
return ok
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_neutral_label_uses_submission_date():
|
| 50 |
+
label = _submitted_label(datetime(2026, 3, 14))
|
| 51 |
+
assert label == "CitationEdge Corpus · submitted 2026-03-14"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_neutral_label_without_date():
|
| 55 |
+
assert _submitted_label(None) == "CitationEdge Corpus · submitted unknown-date"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def test_indexed_document_is_found_by_internal_provider(fake_mongo):
|
| 59 |
+
assert await _index(fake_mongo) is True
|
| 60 |
+
|
| 61 |
+
provider = InternalCorpusProvider(fake_mongo, doc_id="doc_new", k=5, w=4)
|
| 62 |
+
phrase = "quantum coherent transport through a one dimensional chain"
|
| 63 |
+
candidates = await provider.search_phrase(phrase, limit=10)
|
| 64 |
+
|
| 65 |
+
assert len(candidates) == 1
|
| 66 |
+
assert candidates[0].candidate_id == "doc_a"
|
| 67 |
+
assert candidates[0].provider == "CitationEdge Corpus"
|
| 68 |
+
assert candidates[0].bucket is Bucket.INTERNAL
|
| 69 |
+
|
| 70 |
+
text = await provider.fetch_text(candidates[0])
|
| 71 |
+
assert text and "quantum coherent transport" in text
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def test_self_match_guard_excludes_own_doc_id(fake_mongo):
|
| 75 |
+
await _index(fake_mongo, doc_id="doc_a")
|
| 76 |
+
# Scoring document A against the corpus must not return A itself.
|
| 77 |
+
provider = InternalCorpusProvider(fake_mongo, doc_id="doc_a", k=5, w=4)
|
| 78 |
+
phrase = "quantum coherent transport through a one dimensional chain"
|
| 79 |
+
assert await provider.search_phrase(phrase, limit=10) == []
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def test_self_match_guard_excludes_job_id_on_recheck(fake_mongo):
|
| 83 |
+
await _index(fake_mongo, doc_id="doc_a")
|
| 84 |
+
# A recheck of the same upload reuses doc_id but the job differs; the
|
| 85 |
+
# guard must exclude on either axis.
|
| 86 |
+
provider = InternalCorpusProvider(fake_mongo, doc_id="doc_a", job_id="job_doc_a", k=5, w=4)
|
| 87 |
+
phrase = "quantum coherent transport through a one dimensional chain"
|
| 88 |
+
assert await provider.search_phrase(phrase, limit=10) == []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def test_duplicate_submission_scores_approximately_100(fake_mongo):
|
| 92 |
+
"""Exit criterion: index A, then score A against the corpus => ~100%.
|
| 93 |
+
|
| 94 |
+
Simulates the paper-submitted-twice case: document A was indexed in an
|
| 95 |
+
earlier job; a fresh, identical upload (new doc_id/job_id) must find it
|
| 96 |
+
and verify ~full coverage.
|
| 97 |
+
"""
|
| 98 |
+
await _index(fake_mongo, doc_id="doc_a")
|
| 99 |
+
nd_new = normalize(DOC_TEXT)
|
| 100 |
+
|
| 101 |
+
# The fresh submission runs the read path with a NEW doc/job identity.
|
| 102 |
+
provider = InternalCorpusProvider(fake_mongo, doc_id="doc_b", job_id="job_b", k=5, w=4)
|
| 103 |
+
candidates = await provider.search_phrase(
|
| 104 |
+
"quantum coherent transport through a one dimensional chain", limit=10
|
| 105 |
+
)
|
| 106 |
+
assert len(candidates) == 1 and candidates[0].candidate_id == "doc_a"
|
| 107 |
+
|
| 108 |
+
text = await provider.fetch_text(candidates[0])
|
| 109 |
+
spans = match_documents(nd_new, normalize(text), k=5, w=4, min_match=8)
|
| 110 |
+
matched = sum(s.word_count for s in spans)
|
| 111 |
+
assert matched >= int(0.99 * nd_new.word_count)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def test_internal_source_match_carries_only_neutral_label(fake_mongo):
|
| 115 |
+
"""The exit-criterion privacy test: no title/URL/authors/text leaked."""
|
| 116 |
+
await _index(
|
| 117 |
+
fake_mongo,
|
| 118 |
+
created_at=datetime(2026, 3, 14),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
provider = InternalCorpusProvider(fake_mongo, doc_id="doc_new", k=5, w=4)
|
| 122 |
+
candidates = await provider.search_phrase(
|
| 123 |
+
"quantum coherent transport through a one dimensional chain", limit=10
|
| 124 |
+
)
|
| 125 |
+
assert candidates, "candidate should be found first"
|
| 126 |
+
|
| 127 |
+
text = await provider.fetch_text(candidates[0])
|
| 128 |
+
nd_doc = normalize(DOC_TEXT)
|
| 129 |
+
nd_src = normalize(text)
|
| 130 |
+
spans = match_documents(nd_doc, nd_src, k=5, w=4, min_match=8)
|
| 131 |
+
assert spans, "a verbatim match should be verified"
|
| 132 |
+
|
| 133 |
+
schema_spans = [
|
| 134 |
+
MatchSpan(
|
| 135 |
+
doc_start_word=s.doc_start_word,
|
| 136 |
+
doc_end_word=s.doc_end_word,
|
| 137 |
+
doc_char_start=s.doc_char_start,
|
| 138 |
+
doc_char_end=s.doc_char_end,
|
| 139 |
+
word_count=s.word_count,
|
| 140 |
+
excerpt=s.excerpt,
|
| 141 |
+
source_excerpt=s.source_excerpt,
|
| 142 |
+
source_index=1,
|
| 143 |
+
)
|
| 144 |
+
for s in spans
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
matched_words = sum(s.word_count for s in spans)
|
| 148 |
+
result = compute_percentages(
|
| 149 |
+
nd_doc.word_count,
|
| 150 |
+
[Match(source_index=1, bucket=Bucket.INTERNAL, trust=3, doc_start=0, doc_end=nd_doc.word_count)],
|
| 151 |
+
)
|
| 152 |
+
source = provider.build_source_match(
|
| 153 |
+
candidates[0],
|
| 154 |
+
source_index=1,
|
| 155 |
+
matched_words=matched_words,
|
| 156 |
+
percent=result.overall_percent,
|
| 157 |
+
spans=schema_spans,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# The neutral label is the ONLY thing about the other document allowed out.
|
| 161 |
+
assert source.title == "CitationEdge Corpus · submitted 2026-03-14"
|
| 162 |
+
assert source.url == "CitationEdge Corpus · submitted 2026-03-14"
|
| 163 |
+
assert source.authors == []
|
| 164 |
+
assert source.doi is None
|
| 165 |
+
assert source.provider == "CitationEdge Corpus"
|
| 166 |
+
assert "someone@else.org" not in source.model_dump_json()
|
| 167 |
+
assert "Another researcher" not in source.model_dump_json()
|
| 168 |
+
# No text from the other document may escape in a span excerpt.
|
| 169 |
+
for span in source.spans:
|
| 170 |
+
assert span.source_excerpt == ""
|
tests/similarity/test_selector.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query-phrase selection tests.
|
| 2 |
+
|
| 3 |
+
Phrases must be distinctive, spread across the document, and never boilerplate
|
| 4 |
+
or all-stopword. The selected phrases are what get sent to CORE as
|
| 5 |
+
`fullText:"..."` queries, so their quality bounds the recall of stage 1.
|
| 6 |
+
"""
|
| 7 |
+
from similarity.exclusions import find_exclusions
|
| 8 |
+
from similarity.normalize import normalize
|
| 9 |
+
from similarity.selector import select_phrases
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _doc(text, **kw):
|
| 13 |
+
nd = normalize(text)
|
| 14 |
+
return nd, find_exclusions(nd, **kw)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_selects_distinctive_phrases():
|
| 18 |
+
text = (
|
| 19 |
+
"In this paper we propose a novel architecture for question answering. "
|
| 20 |
+
"The encoder stack uses multi head attention mechanisms and positional "
|
| 21 |
+
"encodings. We evaluate against the standard benchmark suite and report "
|
| 22 |
+
"improvements across every task category. The decoder combines token "
|
| 23 |
+
"probabilities with beam search decoding strategies. Training uses a "
|
| 24 |
+
"masked language objective over a large unlabeled corpus sample."
|
| 25 |
+
)
|
| 26 |
+
nd, ex = _doc(text)
|
| 27 |
+
phrases = select_phrases(nd, ex, max_queries=4)
|
| 28 |
+
assert len(phrases) > 0
|
| 29 |
+
assert all(len(p.split()) >= 8 for p in phrases)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_boilerplate_phrases_are_never_selected():
|
| 33 |
+
text = (
|
| 34 |
+
"In this paper we propose a method to improve results significantly. "
|
| 35 |
+
"The rest of this paper is organized as follows section two describes "
|
| 36 |
+
"related work and section three presents our approach in detail. "
|
| 37 |
+
"We then discuss experimental findings and conclude with a summary of "
|
| 38 |
+
"contributions and directions for future work."
|
| 39 |
+
)
|
| 40 |
+
nd, ex = _doc(text)
|
| 41 |
+
phrases = select_phrases(nd, ex, max_queries=4)
|
| 42 |
+
joined = " | ".join(p.lower() for p in phrases)
|
| 43 |
+
assert "rest of this paper is organized" not in joined
|
| 44 |
+
assert "in this paper we propose" not in joined
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_all_stopword_phrases_are_never_selected():
|
| 48 |
+
text = (
|
| 49 |
+
"This is a test of the system and the way that it works. "
|
| 50 |
+
"The and of in to for with at by on as an or if then so but. "
|
| 51 |
+
"Quantum entanglement produces correlated measurement outcomes across "
|
| 52 |
+
"spatially separated laboratories. The experiment confirms the "
|
| 53 |
+
"violation of a bell inequality under strict locality conditions."
|
| 54 |
+
)
|
| 55 |
+
nd, ex = _doc(text)
|
| 56 |
+
phrases = select_phrases(nd, ex, max_queries=4)
|
| 57 |
+
for p in phrases:
|
| 58 |
+
tokens = p.lower().split()
|
| 59 |
+
assert len([t for t in tokens if t in _ALL_STOPWORDS]) < len(tokens)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
_ALL_STOPWORDS = {
|
| 63 |
+
"a", "an", "the", "of", "on", "in", "to", "for", "and", "or", "is", "are",
|
| 64 |
+
"was", "were", "be", "been", "being", "it", "its", "this", "that", "these",
|
| 65 |
+
"those", "with", "from", "by", "as", "at", "into", "through", "between",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_phrases_are_spread_across_the_document():
|
| 70 |
+
text = (
|
| 71 |
+
"The first section introduces the topic and motivates the study with "
|
| 72 |
+
"concrete examples drawn from recent literature surveys. "
|
| 73 |
+
"The second section develops the mathematical framework and derives "
|
| 74 |
+
"the main theoretical results with complete proofs. "
|
| 75 |
+
"The third section describes the experimental setup and the datasets "
|
| 76 |
+
"used to validate the proposed methodology thoroughly. "
|
| 77 |
+
"The fourth section reports the empirical results and compares them "
|
| 78 |
+
"against several strong baseline methods from the literature."
|
| 79 |
+
)
|
| 80 |
+
nd, ex = _doc(text)
|
| 81 |
+
phrases = select_phrases(nd, ex, max_queries=4)
|
| 82 |
+
positions = [_phrase_start(nd, p) for p in phrases]
|
| 83 |
+
assert max(positions) - min(positions) > len(nd.words) // 2
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _phrase_start(nd, phrase):
|
| 87 |
+
"""Locate the real start index of `phrase` inside the document."""
|
| 88 |
+
tokens = phrase.split()
|
| 89 |
+
n = len(tokens)
|
| 90 |
+
for i in range(len(nd.words) - n + 1):
|
| 91 |
+
if nd.words[i].lower() == tokens[0].lower() and (
|
| 92 |
+
" ".join(nd.words[i:i + n]).lower() == phrase.lower()
|
| 93 |
+
):
|
| 94 |
+
return i
|
| 95 |
+
return -1
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_no_candidates_in_short_document():
|
| 99 |
+
nd, ex = _doc("just a few words here")
|
| 100 |
+
assert select_phrases(nd, ex, max_queries=4) == []
|
tests/similarity/test_self_match_guard.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preprint self-match guard tests (plan.md §16 risk 2).
|
| 2 |
+
|
| 3 |
+
A published paper matches its own arXiv preprint at ~100% and would produce
|
| 4 |
+
a terrifying, meaningless score - the most likely first bug report. After
|
| 5 |
+
retrieval and before scoring, drop any candidate whose normalized title has
|
| 6 |
+
>= 0.9 token overlap with the uploaded paper's title, or whose DOI matches,
|
| 7 |
+
or whose author set overlaps by >= 50% while similarity is > 60%.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from similarity.pipeline import (
|
| 13 |
+
_token_overlap,
|
| 14 |
+
_author_overlap,
|
| 15 |
+
is_preprint_self_match,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_token_overlap_perfect():
|
| 20 |
+
a = "attention is all you need"
|
| 21 |
+
b = "attention is all you need"
|
| 22 |
+
assert _token_overlap(a, b) == 1.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_token_overlap_partial():
|
| 26 |
+
a = "attention is all you need a paper"
|
| 27 |
+
b = "attention is all you need"
|
| 28 |
+
assert _token_overlap(a, b) >= 0.9
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_token_overlap_low():
|
| 32 |
+
a = "quantum computing circuits"
|
| 33 |
+
b = "attention is all you need"
|
| 34 |
+
assert _token_overlap(a, b) < 0.5
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_author_overlap_half():
|
| 38 |
+
assert _author_overlap(["a", "b"], ["a", "b"]) == 1.0
|
| 39 |
+
assert _author_overlap(["a", "b", "c"], ["a", "b", "x"]) >= 0.5
|
| 40 |
+
assert _author_overlap(["a"], ["x", "y"]) == 0.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_self_match_by_title():
|
| 44 |
+
assert is_preprint_self_match(
|
| 45 |
+
doc_title="Attention Is All You Need",
|
| 46 |
+
cand_title="Attention is all you need",
|
| 47 |
+
cand_doi=None,
|
| 48 |
+
cand_authors=[],
|
| 49 |
+
similarity=95,
|
| 50 |
+
) is True
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_self_match_by_doi():
|
| 54 |
+
assert is_preprint_self_match(
|
| 55 |
+
doc_title="My Paper",
|
| 56 |
+
cand_title="My Paper (preprint)",
|
| 57 |
+
cand_doi="10.1007/x",
|
| 58 |
+
cand_authors=[],
|
| 59 |
+
similarity=95,
|
| 60 |
+
doc_doi="10.1007/x",
|
| 61 |
+
) is True
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_self_match_by_authors_at_high_similarity():
|
| 65 |
+
# same author team + high similarity + partially overlapping title
|
| 66 |
+
assert is_preprint_self_match(
|
| 67 |
+
doc_title="Improving speech recognition",
|
| 68 |
+
cand_title="Improving speech recognition with deep models",
|
| 69 |
+
cand_doi=None,
|
| 70 |
+
cand_authors=["Alice", "Bob", "Carol"],
|
| 71 |
+
similarity=95,
|
| 72 |
+
doc_authors=["Alice", "Bob"],
|
| 73 |
+
) is True
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_not_self_match_distinct_paper():
|
| 77 |
+
assert is_preprint_self_match(
|
| 78 |
+
doc_title="Quantum coherent transport",
|
| 79 |
+
cand_title="A completely different paper about gardens",
|
| 80 |
+
cand_doi="10.1016/garden",
|
| 81 |
+
cand_authors=["Zoe"],
|
| 82 |
+
similarity=2,
|
| 83 |
+
) is False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_not_self_match_shared_author_low_similarity():
|
| 87 |
+
# same authors but low similarity => it is a genuine different work
|
| 88 |
+
assert is_preprint_self_match(
|
| 89 |
+
doc_title="Quantum rings",
|
| 90 |
+
cand_title="Another work by the same lab",
|
| 91 |
+
cand_doi=None,
|
| 92 |
+
cand_authors=["Alice", "Bob"],
|
| 93 |
+
similarity=10,
|
| 94 |
+
) is False
|
tests/similarity/test_semantic.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic (paraphrase) pass tests (plan.md §4/§5, prompt Phase 3).
|
| 2 |
+
|
| 3 |
+
The headline percentage is VERBATIM ONLY. The paraphrase pass may flag spans
|
| 4 |
+
for human review (`paraphrase_flags`), but it must NEVER move
|
| 5 |
+
`overall_percent` - otherwise a fuzzy embedding could turn 2% into 80% and
|
| 6 |
+
the whole corpus claim collapses. This is asserted directly.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from similarity.semantic import semantic_pass
|
| 12 |
+
from similarity.schema import Bucket, MatchSpan, SimilarityReport, Coverage, SourceMatch
|
| 13 |
+
|
| 14 |
+
SOURCE_EXCERPT = "the quick brown fox jumps over the lazy dog near the river"
|
| 15 |
+
DOC_EXCERPT = "the swift brown fox leaps above the idle canine beside the stream"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _report(excerpts: list[str] | None = None) -> SimilarityReport:
|
| 19 |
+
spans = [
|
| 20 |
+
MatchSpan(
|
| 21 |
+
doc_start_word=0, doc_end_word=10, doc_char_start=0, doc_char_end=40,
|
| 22 |
+
word_count=10, excerpt=DOC_EXCERPT, source_excerpt=SOURCE_EXCERPT,
|
| 23 |
+
source_index=1,
|
| 24 |
+
)
|
| 25 |
+
]
|
| 26 |
+
return SimilarityReport(
|
| 27 |
+
overall_percent=50,
|
| 28 |
+
bucket_percents={Bucket.OPEN_ACCESS: 50},
|
| 29 |
+
sources=[
|
| 30 |
+
SourceMatch(
|
| 31 |
+
source_index=1,
|
| 32 |
+
bucket=Bucket.OPEN_ACCESS,
|
| 33 |
+
title="A real source",
|
| 34 |
+
url="https://example.org/paper",
|
| 35 |
+
provider="fake",
|
| 36 |
+
display_label="https://example.org/paper",
|
| 37 |
+
matched_words=10,
|
| 38 |
+
percent=10,
|
| 39 |
+
spans=spans,
|
| 40 |
+
)
|
| 41 |
+
],
|
| 42 |
+
coverage=Coverage(
|
| 43 |
+
total_words_compared=100, words_excluded=0,
|
| 44 |
+
providers_queried=["CORE"], phrases_queried=24,
|
| 45 |
+
candidates_retrieved=5, candidates_verified=5,
|
| 46 |
+
internal_corpus_size=10, min_match_words=8,
|
| 47 |
+
),
|
| 48 |
+
status="complete",
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_semantic_pass_never_changes_headline_percent():
|
| 53 |
+
base = _report()
|
| 54 |
+
|
| 55 |
+
class FakeEmbedder:
|
| 56 |
+
def embed(self, text):
|
| 57 |
+
# one dimension so cosine is trivially 1.0 for any pair
|
| 58 |
+
return [1.0]
|
| 59 |
+
|
| 60 |
+
def cosine_similarity(self, a, b):
|
| 61 |
+
return 1.0
|
| 62 |
+
|
| 63 |
+
out = semantic_pass(base, doc_text=DOC_EXCERPT, embedder=FakeEmbedder())
|
| 64 |
+
# paraphrase is real (flagged for review) ...
|
| 65 |
+
assert len(out.paraphrase_flags) == 1
|
| 66 |
+
assert out.paraphrase_flags[0].source_index == 1
|
| 67 |
+
# ... but the headline NEVER moved.
|
| 68 |
+
assert out.overall_percent == base.overall_percent == 50
|
| 69 |
+
assert out.bucket_percents == base.bucket_percents
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_semantic_pass_disabled_by_config(monkeypatch):
|
| 73 |
+
monkeypatch.setattr("similarity.semantic.settings.enable_paraphrase", False)
|
| 74 |
+
base = _report()
|
| 75 |
+
out = semantic_pass(base, doc_text=DOC_EXCERPT, embedder=None)
|
| 76 |
+
assert out == base
|
| 77 |
+
assert out.paraphrase_flags == []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_semantic_pass_returns_empty_when_no_embedder():
|
| 81 |
+
base = _report()
|
| 82 |
+
out = semantic_pass(base, doc_text=DOC_EXCERPT, embedder=None)
|
| 83 |
+
# without an embedder there is nothing to compare; no flags, no score change
|
| 84 |
+
assert out.paraphrase_flags == []
|
tests/test_agents.py
CHANGED
|
@@ -5,6 +5,7 @@ from agents.scoring_agent import ScoringAgent
|
|
| 5 |
from agents.argumentation_agent import ArgumentationAgent
|
| 6 |
from agents.citation_relevance_agent import CitationRelevanceAgent
|
| 7 |
from agents.novelty_agent import NoveltyAgent
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
@pytest.mark.asyncio
|
|
@@ -220,3 +221,16 @@ async def test_novelty_survives_llm_and_search_failure(agent_ctx, mock_neo4j, mo
|
|
| 220 |
result = await agent.run(agent_ctx)
|
| 221 |
assert result.status == AgentStatus.COMPLETED
|
| 222 |
assert result.data["verdict"] == "UNKNOWN"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from agents.argumentation_agent import ArgumentationAgent
|
| 6 |
from agents.citation_relevance_agent import CitationRelevanceAgent
|
| 7 |
from agents.novelty_agent import NoveltyAgent
|
| 8 |
+
from agents.similarity_agent import SimilarityAgent
|
| 9 |
|
| 10 |
|
| 11 |
@pytest.mark.asyncio
|
|
|
|
| 221 |
result = await agent.run(agent_ctx)
|
| 222 |
assert result.status == AgentStatus.COMPLETED
|
| 223 |
assert result.data["verdict"] == "UNKNOWN"
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@pytest.mark.asyncio
|
| 227 |
+
async def test_similarity_agent_unavailable_on_short_text(agent_ctx, mock_neo4j):
|
| 228 |
+
"""SimilarityAgent must return an 'unavailable' report, not crash, on short text."""
|
| 229 |
+
mock_neo4j.run.return_value = [
|
| 230 |
+
{"text": "a short paragraph with very few words in it"}
|
| 231 |
+
]
|
| 232 |
+
agent = SimilarityAgent()
|
| 233 |
+
result = await agent.run(agent_ctx)
|
| 234 |
+
assert result.status == AgentStatus.COMPLETED
|
| 235 |
+
assert result.data.get("status") == "unavailable"
|
| 236 |
+
assert result.data.get("overall_percent") == 0
|