lllin000_PaperForge/paperforge/memory/query.py
Research Assistant f68f45f3a2 fix: improve search accuracy with multi-token LIKE and guided agent search queries
- _like_query: split multi-token queries into per-field AND matching
- lookup_paper: same multi-token AND for title lookup
- read-known-paper.md: restructure Step 1 by known-info type (zotero_key,
  DOI, author+year, title keyword, fuzzy)
- discover-papers.md Arm 2: note on author+year search pattern
- retrieval-routing.md Arm 2: same author+year search guidance
2026-05-25 16:42:11 +08:00

194 lines
6.2 KiB
Python

from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from paperforge.memory.builder import compute_hash
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
logger = logging.getLogger(__name__)
def get_memory_status(vault: Path) -> dict:
"""Check paperforge.db health and staleness.
Returns a dict with: db_exists, schema_ok, fresh, count_match,
paper_count_db, paper_count_index, needs_rebuild.
"""
db_path = get_memory_db_path(vault)
result = {
"db_exists": db_path.exists(),
"schema_ok": False,
"fresh": False,
"hash_match": False,
"count_match": False,
"paper_count_db": 0,
"paper_count_index": 0,
"needs_rebuild": True,
}
if not db_path.exists():
return result
conn = get_connection(db_path, read_only=True)
try:
stored_version = get_schema_version(conn)
result["schema_ok"] = stored_version == CURRENT_SCHEMA_VERSION
row = conn.execute("SELECT COUNT(*) as cnt FROM papers").fetchone()
result["paper_count_db"] = row["cnt"] if row else 0
stored_hash_row = conn.execute(
"SELECT value FROM meta WHERE key = 'canonical_index_hash'"
).fetchone()
stored_hash = stored_hash_row["value"] if stored_hash_row else ""
except Exception:
return result
finally:
conn.close()
envelope = read_index(vault)
if envelope is not None:
# Handle legacy format (bare list)
if isinstance(envelope, list):
items = envelope
paper_count = len(items)
index_hash = compute_hash(items)
else:
items = envelope.get("items", [])
paper_count = envelope.get("paper_count", 0)
index_hash = compute_hash(items)
result["paper_count_index"] = paper_count
# Compare stored hash with computed hash
result["hash_match"] = stored_hash == index_hash
result["count_match"] = (
result["paper_count_db"] == result["paper_count_index"]
)
result["fresh"] = (
result["schema_ok"]
and result["count_match"]
and result.get("hash_match", False)
)
result["needs_rebuild"] = not result["fresh"]
return result
def _entry_from_row(row) -> dict:
"""Reconstruct an entry dict from a papers row (sqlite3.Row)."""
entry = {k: row[k] for k in row}
for key in ("has_pdf", "do_ocr", "analyze"):
if key in entry and entry[key] is not None:
entry[key] = bool(entry[key])
for key in ("authors_json", "collections_json"):
if key in entry and entry[key]:
try:
entry[key[:-5]] = json.loads(entry[key])
del entry[key]
except json.JSONDecodeError:
logger.warning(
"Corrupted JSON in column %s for paper %s",
key, entry.get("zotero_key", "?"),
)
return entry
def lookup_paper(conn, query: str) -> list[dict]:
"""Multi-strategy lookup. Returns list of matching paper dicts."""
q = query.strip()
for lookup_col in ("zotero_key", "citation_key", "doi"):
row = conn.execute(
f"SELECT * FROM papers WHERE LOWER({lookup_col}) = LOWER(?)",
(q,),
).fetchone()
if row:
return [_entry_from_row(row)]
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]
rows = conn.execute(
f"""SELECT * FROM papers
WHERE {title_conditions}
LIMIT 20""",
title_params,
).fetchall()
if rows:
return [_entry_from_row(r) for r in rows]
rows = conn.execute(
"""SELECT p.* FROM papers p
JOIN paper_aliases a ON a.paper_id = p.zotero_key
WHERE a.alias_norm LIKE '%' || LOWER(?) || '%'
LIMIT 20""",
(q,),
).fetchall()
return [_entry_from_row(r) for r in rows]
def get_paper_assets(conn, zotero_key: str) -> list[dict]:
rows = conn.execute(
"SELECT asset_type, path, exists_on_disk FROM paper_assets WHERE paper_id = ?",
(zotero_key,),
).fetchall()
return [dict(r) for r in rows]
def get_paper_status(vault: Path, query: str) -> dict | None:
"""Full paper status lookup. Returns dict or None if not found.
If multiple candidates found, returns a candidate list without full status.
"""
db_path = get_memory_db_path(vault)
if not db_path.exists():
return None
conn = get_connection(db_path, read_only=True)
try:
entries = lookup_paper(conn, query)
if not entries:
return None
# Multiple candidates -> return candidate list only (no full status)
if len(entries) > 1:
return {
"resolved": False,
"candidates": [
{
"zotero_key": e.get("zotero_key"),
"title": e.get("title"),
"year": e.get("year"),
"citation_key": e.get("citation_key"),
"lifecycle": e.get("lifecycle"),
}
for e in entries
],
}
entry = entries[0]
assets = get_paper_assets(conn, entry["zotero_key"])
entry["health"] = compute_health(entry)
entry["assets"] = assets
entry["resolved"] = True
next_step = entry.get("next_step", "")
zk = entry.get("zotero_key", "")
if next_step == "/pf-deep":
entry["recommended_action"] = f"/pf-deep {zk}"
elif next_step == "ocr":
entry["recommended_action"] = f"paperforge ocr --key {zk}"
elif next_step == "sync":
entry["recommended_action"] = "paperforge sync"
else:
entry["recommended_action"] = None
return entry
finally:
conn.close()