omkarkudalkar222 commited on
Commit
fa364c7
·
1 Parent(s): 057a2a1

deploy: similarity feature + keyless providers (squashed for HF Spaces)

Browse files
Files changed (50) hide show
  1. agents/__init__.py +1 -0
  2. agents/similarity_agent.py +100 -0
  3. backend/main.py +2 -0
  4. backend/routers/jobs.py +4 -0
  5. backend/routers/reports.py +28 -0
  6. backend/routers/similarity.py +164 -0
  7. backend/schemas/similarity.py +34 -0
  8. backend/tests/conftest.py +4 -0
  9. backend/tests/test_jobs.py +8 -0
  10. backend/tests/test_similarity.py +146 -0
  11. configs/pipeline_config.yaml +4 -0
  12. opencode-prompt.md +357 -0
  13. orchestrators/custom_orchestrator.py +20 -0
  14. plan.md +918 -0
  15. plan_done.md +143 -0
  16. services/academic_search.py +217 -0
  17. services/paper_recommender.py +27 -213
  18. similarity/__init__.py +0 -0
  19. similarity/aggregate.py +98 -0
  20. similarity/config.py +57 -0
  21. similarity/corpus/__init__.py +0 -0
  22. similarity/corpus/arxiv.py +120 -0
  23. similarity/corpus/base.py +43 -0
  24. similarity/corpus/core_api.py +108 -0
  25. similarity/corpus/crossref.py +84 -0
  26. similarity/corpus/internal.py +112 -0
  27. similarity/corpus/openalex.py +85 -0
  28. similarity/exclusions.py +129 -0
  29. similarity/fingerprint.py +69 -0
  30. similarity/index_writer.py +98 -0
  31. similarity/matcher.py +136 -0
  32. similarity/normalize.py +51 -0
  33. similarity/pipeline.py +303 -0
  34. similarity/schema.py +82 -0
  35. similarity/selector.py +109 -0
  36. similarity/semantic.py +54 -0
  37. tests/similarity/conftest.py +115 -0
  38. tests/similarity/test_aggregate.py +109 -0
  39. tests/similarity/test_corpus_providers.py +329 -0
  40. tests/similarity/test_exclusions.py +77 -0
  41. tests/similarity/test_fingerprint.py +89 -0
  42. tests/similarity/test_index_writer.py +103 -0
  43. tests/similarity/test_matcher.py +87 -0
  44. tests/similarity/test_normalize.py +52 -0
  45. tests/similarity/test_pipeline.py +205 -0
  46. tests/similarity/test_privacy.py +170 -0
  47. tests/similarity/test_selector.py +100 -0
  48. tests/similarity/test_self_match_guard.py +94 -0
  49. tests/similarity/test_semantic.py +84 -0
  50. 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 — biomedical literature + preprints + patents (keyless)
15
- 5. PubMed — MEDLINE abstracts via NCBI E-utilities (keyless)
 
 
 
 
 
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 _PROVIDER_SEM:
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
- # Enrich top recommendations with open-access copies (silent on failure)
559
  for rec in recs:
560
- if not rec.get("doi"):
561
- continue
562
- try:
563
- result = await self._unpaywall.run(rec["doi"])
564
- if result.success and result.data and result.data.get("url"):
565
- rec["oa_url"] = result.data["url"]
566
- except Exception as e:
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 + Europe PMC +
583
- PubMed) without ranking: returns candidates labeled with source,
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*&gt;+\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