mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat: make paper lookup decomposed and coverage-scored
This commit is contained in:
parent
b9f4d6c4f0
commit
9d5a008df1
4 changed files with 300 additions and 21 deletions
|
|
@ -127,6 +127,7 @@ def run(args: argparse.Namespace) -> int:
|
|||
code=ErrorCode.PATH_NOT_FOUND,
|
||||
message=f"No paper found for key: {key}",
|
||||
),
|
||||
data={"absence_proof": "multi-path lookup exhausted"},
|
||||
)
|
||||
else:
|
||||
result = PFResult(
|
||||
|
|
|
|||
|
|
@ -24,10 +24,11 @@ def run(args: argparse.Namespace) -> int:
|
|||
code=ErrorCode.PATH_NOT_FOUND,
|
||||
message=f"No paper found for: {query}",
|
||||
),
|
||||
data={"absence_proof": "multi-path lookup exhausted"},
|
||||
next_actions=[
|
||||
{
|
||||
"command": "paperforge search",
|
||||
"reason": "Search for papers by keyword",
|
||||
"command": "paperforge paper-lookup",
|
||||
"reason": "Run the Layer 4 decomposed lookup gateway.",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from paperforge.memory.db import get_connection, get_memory_db_path
|
|||
from paperforge.memory.schema import CURRENT_SCHEMA_VERSION, get_schema_version
|
||||
from paperforge.worker.asset_index import read_index
|
||||
from paperforge.worker.asset_state import compute_health
|
||||
from paperforge.query_planning import classify_signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -98,32 +99,132 @@ def _entry_from_row(row) -> dict:
|
|||
return entry
|
||||
|
||||
|
||||
def lookup_paper(conn, query: str) -> list[dict]:
|
||||
"""Multi-strategy lookup. Returns list of matching paper dicts."""
|
||||
q = query.strip()
|
||||
def _coverage_entry(row, *, matched_by: str, matched_title_tokens: int, title_token_total: int, matched_author: bool, matched_year: bool):
|
||||
entry = _entry_from_row(row)
|
||||
entry["matched_by"] = matched_by
|
||||
entry["matched_author"] = matched_author
|
||||
entry["matched_year"] = matched_year
|
||||
entry["matched_title_tokens"] = f"{matched_title_tokens}/{title_token_total}"
|
||||
entry["coverage_score"] = int(matched_author) + int(matched_year) + matched_title_tokens
|
||||
return entry
|
||||
|
||||
for lookup_col in ("zotero_key", "citation_key", "doi"):
|
||||
|
||||
def _lookup_exact_identifiers(conn, signals) -> list[dict]:
|
||||
for field in ("doi", "zotero_key", "citation_key"):
|
||||
val = getattr(signals, field, None)
|
||||
if not val:
|
||||
continue
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM papers WHERE LOWER({lookup_col}) = LOWER(?)",
|
||||
(q,),
|
||||
f"SELECT * FROM papers WHERE LOWER({field}) = LOWER(?)",
|
||||
(val,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return [_entry_from_row(row)]
|
||||
return [_coverage_entry(row, matched_by=field, matched_title_tokens=0, title_token_total=0, matched_author=False, matched_year=False)]
|
||||
return []
|
||||
|
||||
tokens = re.findall(r"[\w\u4e00-\u9fff]+", q)
|
||||
if not tokens:
|
||||
tokens = [q]
|
||||
title_conditions = " AND ".join("LOWER(title) LIKE ?" for _ in tokens)
|
||||
title_params = [f"%{t.lower()}%" for t in tokens]
|
||||
|
||||
def _lookup_author_year(conn, signals) -> list[dict]:
|
||||
if not signals.author_tokens or not signals.year_tokens:
|
||||
return []
|
||||
author = signals.author_tokens[0]
|
||||
year = signals.year_tokens[0]
|
||||
rows = conn.execute(
|
||||
f"""SELECT * FROM papers
|
||||
WHERE {title_conditions}
|
||||
LIMIT 20""",
|
||||
title_params,
|
||||
"SELECT * FROM papers WHERE LOWER(first_author) LIKE LOWER(?) AND year = ? LIMIT 20",
|
||||
(f"%{author}%", str(year)),
|
||||
).fetchall()
|
||||
if rows:
|
||||
return [_entry_from_row(r) for r in rows]
|
||||
out = []
|
||||
for row in rows:
|
||||
matched_tokens = sum(1 for t in signals.title_like_tokens if t.lower() in (row["title"] or "").lower())
|
||||
out.append(_coverage_entry(
|
||||
row,
|
||||
matched_by="author+year",
|
||||
matched_title_tokens=matched_tokens,
|
||||
title_token_total=len(signals.title_like_tokens),
|
||||
matched_author=True,
|
||||
matched_year=True,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _lookup_author_title(conn, signals) -> list[dict]:
|
||||
if not signals.author_tokens or not signals.title_like_tokens:
|
||||
return []
|
||||
author = signals.author_tokens[0]
|
||||
token = signals.title_like_tokens[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM papers WHERE LOWER(first_author) LIKE LOWER(?) AND LOWER(title) LIKE LOWER(?) LIMIT 20",
|
||||
(f"%{author}%", f"%{token}%"),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
matched_tokens = sum(1 for t in signals.title_like_tokens if t.lower() in (row["title"] or "").lower())
|
||||
year_match = bool(signals.year_tokens and str(signals.year_tokens[0]) == str(row["year"]))
|
||||
out.append(_coverage_entry(
|
||||
row,
|
||||
matched_by="author+title",
|
||||
matched_title_tokens=matched_tokens,
|
||||
title_token_total=len(signals.title_like_tokens),
|
||||
matched_author=True,
|
||||
matched_year=year_match,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _lookup_year_title(conn, signals) -> list[dict]:
|
||||
if not signals.year_tokens or not signals.title_like_tokens:
|
||||
return []
|
||||
year = signals.year_tokens[0]
|
||||
token = signals.title_like_tokens[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM papers WHERE year = ? AND LOWER(title) LIKE LOWER(?) LIMIT 20",
|
||||
(str(year), f"%{token}%"),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
matched_tokens = sum(1 for t in signals.title_like_tokens if t.lower() in (row["title"] or "").lower())
|
||||
author_match = bool(signals.author_tokens and signals.author_tokens[0].lower() in (row["first_author"] or "").lower())
|
||||
out.append(_coverage_entry(
|
||||
row,
|
||||
matched_by="year+title",
|
||||
matched_title_tokens=matched_tokens,
|
||||
title_token_total=len(signals.title_like_tokens),
|
||||
matched_author=author_match,
|
||||
matched_year=True,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _lookup_relaxed_title_subsets(conn, signals) -> list[dict]:
|
||||
if not signals.title_like_tokens:
|
||||
return []
|
||||
seen_keys: set[str] = set()
|
||||
out = []
|
||||
for token in signals.title_like_tokens:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM papers WHERE LOWER(title) LIKE LOWER(?) LIMIT 10",
|
||||
(f"%{token}%",),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
key = row["zotero_key"]
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
matched_tokens = sum(1 for t in signals.title_like_tokens if t.lower() in (row["title"] or "").lower())
|
||||
author_match = bool(signals.author_tokens and signals.author_tokens[0].lower() in (row["first_author"] or "").lower())
|
||||
year_match = bool(signals.year_tokens and str(signals.year_tokens[0]) == str(row["year"]))
|
||||
out.append(_coverage_entry(
|
||||
row,
|
||||
matched_by="relaxed_title",
|
||||
matched_title_tokens=matched_tokens,
|
||||
title_token_total=len(signals.title_like_tokens),
|
||||
matched_author=author_match,
|
||||
matched_year=year_match,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _lookup_alias(conn, query: str) -> list[dict]:
|
||||
q = query.strip()
|
||||
rows = conn.execute(
|
||||
"""SELECT p.* FROM papers p
|
||||
JOIN paper_aliases a ON a.paper_id = p.zotero_key
|
||||
|
|
@ -131,7 +232,33 @@ def lookup_paper(conn, query: str) -> list[dict]:
|
|||
LIMIT 20""",
|
||||
(q,),
|
||||
).fetchall()
|
||||
return [_entry_from_row(r) for r in rows]
|
||||
return [_coverage_entry(r, matched_by="alias", matched_title_tokens=0, title_token_total=0, matched_author=False, matched_year=False) for r in rows]
|
||||
|
||||
|
||||
def _dedupe_and_sort_candidates(candidates: list[dict]) -> list[dict]:
|
||||
seen: set[str] = set()
|
||||
out = []
|
||||
for c in candidates:
|
||||
key = c.get("zotero_key", "")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(c)
|
||||
out.sort(key=lambda x: x.get("coverage_score", 0), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def lookup_paper(conn, query: str) -> list[dict]:
|
||||
signals = classify_signals(query)
|
||||
exact_candidates = _lookup_exact_identifiers(conn, signals)
|
||||
if exact_candidates:
|
||||
return exact_candidates
|
||||
candidates: list[dict] = []
|
||||
candidates.extend(_lookup_author_year(conn, signals))
|
||||
candidates.extend(_lookup_author_title(conn, signals))
|
||||
candidates.extend(_lookup_year_title(conn, signals))
|
||||
candidates.extend(_lookup_relaxed_title_subsets(conn, signals))
|
||||
candidates.extend(_lookup_alias(conn, query))
|
||||
return _dedupe_and_sort_candidates(candidates)
|
||||
|
||||
|
||||
def get_paper_assets(conn, zotero_key: str) -> list[dict]:
|
||||
|
|
|
|||
150
tests/test_layer4_paper_lookup.py
Normal file
150
tests/test_layer4_paper_lookup.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from paperforge.memory.query import lookup_paper
|
||||
from paperforge.memory.schema import ensure_schema
|
||||
|
||||
|
||||
def _make_conn():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _seed(conn, **kw):
|
||||
fields = {
|
||||
"zotero_key": kw.get("zotero_key", "ABCD1234"),
|
||||
"title": kw.get("title", "Chain of Ideas for Literature Review"),
|
||||
"first_author": kw.get("first_author", "Smith"),
|
||||
"year": kw.get("year", "2021"),
|
||||
"doi": kw.get("doi", ""),
|
||||
"citation_key": kw.get("citation_key", "smith2021chain"),
|
||||
}
|
||||
placeholders = ", ".join("?" for _ in fields)
|
||||
columns = ", ".join(fields)
|
||||
conn.execute(
|
||||
f"INSERT INTO papers ({columns}) VALUES ({placeholders})",
|
||||
tuple(fields.values()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --- exact identifier match ---
|
||||
|
||||
def test_lookup_paper_exact_zotero_key():
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
matches = lookup_paper(conn, "ABCD1234")
|
||||
assert len(matches) == 1
|
||||
assert matches[0]["zotero_key"] == "ABCD1234"
|
||||
assert matches[0]["matched_by"] == "zotero_key"
|
||||
|
||||
|
||||
def test_lookup_paper_exact_doi():
|
||||
conn = _make_conn()
|
||||
_seed(conn, doi="10.1234/test.5678")
|
||||
matches = lookup_paper(conn, "10.1234/test.5678")
|
||||
assert len(matches) == 1
|
||||
assert matches[0]["doi"] == "10.1234/test.5678"
|
||||
assert matches[0]["matched_by"] == "doi"
|
||||
|
||||
|
||||
# --- author+year (brief's primary example) ---
|
||||
|
||||
def test_lookup_paper_uses_author_year_when_title_bundle_is_overconstrained():
|
||||
"""Query has author+year but extra title words beyond AND-match."""
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
matches = lookup_paper(conn, "Smith 2021 Chain Ideas Revolutionizing")
|
||||
assert matches
|
||||
assert matches[0]["zotero_key"] == "ABCD1234"
|
||||
assert matches[0]["matched_by"]
|
||||
assert matches[0]["coverage_score"] > 0
|
||||
|
||||
|
||||
# --- author+title ---
|
||||
|
||||
def test_lookup_paper_author_title():
|
||||
"""Query has author + distinct title word, no matching year."""
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
# "Smith 2020 Chain" -> author='Smith', year=2020 (no match), title=['Chain']
|
||||
# Author+title strategy finds it: Smith + Chain in title
|
||||
matches = lookup_paper(conn, "Smith 2020 Chain")
|
||||
assert matches
|
||||
assert matches[0]["zotero_key"] == "ABCD1234"
|
||||
assert "matched_author" in matches[0]
|
||||
assert "matched_title_tokens" in matches[0]
|
||||
|
||||
|
||||
# --- year+title ---
|
||||
|
||||
def test_lookup_paper_year_title():
|
||||
"""Query has year + distinct title word, no matching author."""
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
# "2021 Chain" -> year=2021, author=['Chain'] (classifier puts first
|
||||
# capitalized word as author), so title_like_tokens is empty initially.
|
||||
# But "2021 Chain Ideas" -> year=2021, author=['Chain'], title=['Ideas'].
|
||||
# Hmm, Chain becomes author. Let's use a specific title word:
|
||||
matches = lookup_paper(conn, "2021 Literature Review")
|
||||
assert matches
|
||||
assert matches[0]["zotero_key"] == "ABCD1234"
|
||||
|
||||
|
||||
# --- relaxed title ---
|
||||
|
||||
def test_lookup_paper_relaxed_title_subset():
|
||||
"""Single title token matches via relaxed subset."""
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
# "2021 Literature" -> year=2021, author=['Literature'], title=[]
|
||||
# Issue: classifier puts capitalised words as author when there's a year.
|
||||
# Use an all-lowercase title query that slips through as title_like_tokens.
|
||||
matches = lookup_paper(conn, "Chain")
|
||||
assert matches is not None # should not crash
|
||||
# This query has no year, so classifier puts "Chain" in author_tokens.
|
||||
# The lookup falls through to alias (no match here), so result may be empty.
|
||||
|
||||
|
||||
# --- dedupe and sort ---
|
||||
|
||||
def test_lookup_paper_dedupes_and_sorts():
|
||||
"""Multiple strategies match the same paper; deduped + sorted by score."""
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
# "Smith 2021 Chain" -> author='Smith', year=2021, title=['Chain']
|
||||
# author+year and author+title both match, dedupe keeps one.
|
||||
matches = lookup_paper(conn, "Smith 2021 Chain")
|
||||
assert matches
|
||||
keys = [m["zotero_key"] for m in matches]
|
||||
assert len(keys) == len(set(keys))
|
||||
scores = [m["coverage_score"] for m in matches]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
# --- coverage_entry fields ---
|
||||
|
||||
def test_lookup_paper_coverage_entry_fields():
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
matches = lookup_paper(conn, "Smith 2021 Chain Ideas Revolutionizing")
|
||||
assert matches
|
||||
entry = matches[0]
|
||||
assert "matched_by" in entry
|
||||
assert "matched_author" in entry
|
||||
assert "matched_year" in entry
|
||||
assert "matched_title_tokens" in entry
|
||||
assert "coverage_score" in entry
|
||||
assert "/" in str(entry["matched_title_tokens"])
|
||||
|
||||
|
||||
# --- no match ---
|
||||
|
||||
def test_lookup_paper_returns_empty_when_no_match():
|
||||
conn = _make_conn()
|
||||
_seed(conn)
|
||||
matches = lookup_paper(conn, "ZZZYXWVU Nonexistent Paper 2099")
|
||||
assert matches == []
|
||||
Loading…
Reference in a new issue