282 lines
10 KiB
Python
282 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Fast relevance screening for a broad Agent-memory research corpus."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
PROMPT = """Screen papers for a research review about memory in LLM-based agents.
|
|
Use title and abstract only. Return JSON with a `papers` array in input order.
|
|
For each paper return:
|
|
- arxiv_id: exact input id
|
|
- relevance: core if memory is the main research object; supporting if memory is a substantial
|
|
mechanism, evaluation dimension, or failure mode; peripheral if memory is incidental, means
|
|
GPU/KV memory only, refers to human memory only, or the paper is mainly an unrelated application
|
|
- problem: choose one: access, representation, update_forgetting, execution_state, experience_learning,
|
|
evaluation, security_privacy, shared_memory, systems_cost, theory, application, unrelated
|
|
- reason: at most 12 words
|
|
Be conservative and output JSON only."""
|
|
|
|
RELEVANCE_VALUES = {"core", "supporting", "peripheral"}
|
|
PROBLEM_VALUES = {
|
|
"access",
|
|
"representation",
|
|
"update_forgetting",
|
|
"execution_state",
|
|
"experience_learning",
|
|
"evaluation",
|
|
"security_privacy",
|
|
"shared_memory",
|
|
"systems_cost",
|
|
"theory",
|
|
"application",
|
|
"unrelated",
|
|
}
|
|
PROBLEM_ALIASES = {
|
|
"adaptation": "experience_learning",
|
|
"continual_learning": "experience_learning",
|
|
"episodic": "representation",
|
|
"episodic_memory": "representation",
|
|
"experience": "experience_learning",
|
|
"forgetting": "update_forgetting",
|
|
"implicit_personalization": "application",
|
|
"memory": "representation",
|
|
"privacy": "security_privacy",
|
|
"security": "security_privacy",
|
|
}
|
|
|
|
|
|
def write_json(path: Path, payload: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def generate(url: str, model: str, records: list[dict], timeout: int, retries: int) -> list[dict]:
|
|
compact = [
|
|
{"arxiv_id": item["arxiv_id"], "title": item["title"], "abstract": item["abstract"]}
|
|
for item in records
|
|
]
|
|
body = {
|
|
"model": model,
|
|
"prompt": f"{PROMPT}\n\nINPUT:\n{json.dumps(compact, ensure_ascii=False)}",
|
|
"stream": False,
|
|
"format": "json",
|
|
"options": {"temperature": 0, "num_predict": 4000},
|
|
}
|
|
request = urllib.request.Request(
|
|
f"{url.rstrip('/')}/api/generate",
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
for attempt in range(retries + 1):
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
result = json.loads(response.read().decode("utf-8"))
|
|
decoded = json.loads(result["response"])
|
|
if isinstance(decoded, list):
|
|
parsed = decoded
|
|
elif isinstance(decoded, dict) and isinstance(decoded.get("papers"), list):
|
|
parsed = decoded["papers"]
|
|
elif isinstance(decoded, dict) and isinstance(decoded.get("results"), list):
|
|
parsed = decoded["results"]
|
|
elif isinstance(decoded, dict) and decoded.get("arxiv_id"):
|
|
parsed = [decoded]
|
|
else:
|
|
raise ValueError("response lacks a recognized result array")
|
|
expected = [item["arxiv_id"] for item in records]
|
|
by_id = {str(item.get("arxiv_id")): item for item in parsed}
|
|
if any(paper_id not in by_id for paper_id in expected):
|
|
raise ValueError("response ids do not match input")
|
|
return [{**by_id[paper_id], "source": "model"} for paper_id in expected]
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
json.JSONDecodeError,
|
|
KeyError,
|
|
ValueError,
|
|
):
|
|
if attempt >= retries:
|
|
raise
|
|
time.sleep(2 ** attempt)
|
|
raise RuntimeError("unreachable retry state")
|
|
|
|
|
|
def heuristic_screen(record: dict) -> dict:
|
|
title = record["title"].lower()
|
|
text = f"{record['title']} {record['abstract']}".lower()
|
|
title_memory = bool(re.search(r"memory|memori|remember|forget|stateful", title))
|
|
agent_memory = bool(
|
|
re.search(r"agent(?:ic)? memory|memory (?:for|in|of) .*agent|long-term .*agent", text)
|
|
)
|
|
hardware_only = bool(re.search(r"gpu memory|memory footprint|memory bandwidth|memory efficient", title))
|
|
if title_memory and not hardware_only:
|
|
relevance = "core"
|
|
elif agent_memory:
|
|
relevance = "supporting"
|
|
else:
|
|
relevance = "peripheral"
|
|
|
|
if re.search(r"attack|poison|privacy|security|trust|provenance|leak", text):
|
|
problem = "security_privacy"
|
|
elif re.search(r"benchmark|evaluation|diagnos|measure", title):
|
|
problem = "evaluation"
|
|
elif re.search(r"update|forget|consolidat|stale|supersed|write", text):
|
|
problem = "update_forgetting"
|
|
elif re.search(r"skill|experience|reflect|self-evolv|continual learn", text):
|
|
problem = "experience_learning"
|
|
elif re.search(r"execution state|task state|workflow state|long-horizon", text):
|
|
problem = "execution_state"
|
|
elif re.search(r"graph|hierarch|represent|structure|database|document", text):
|
|
problem = "representation"
|
|
elif re.search(r"retriev|search|recall|access", text):
|
|
problem = "access"
|
|
elif re.search(r"multi-agent|shared memory|collective memory", text):
|
|
problem = "shared_memory"
|
|
elif re.search(r"cost|latency|token|efficient|compress", text):
|
|
problem = "systems_cost"
|
|
else:
|
|
problem = "application" if relevance != "peripheral" else "unrelated"
|
|
return {
|
|
"arxiv_id": record["arxiv_id"],
|
|
"relevance": relevance,
|
|
"problem": problem,
|
|
"reason": "deterministic fallback after model-format failure",
|
|
"source": "heuristic",
|
|
}
|
|
|
|
|
|
def normalize_analysis(record: dict, analysis: dict | None) -> dict:
|
|
"""Constrain model output to the documented screening schema."""
|
|
fallback = heuristic_screen(record)
|
|
analysis = analysis if isinstance(analysis, dict) else {}
|
|
relevance = str(analysis.get("relevance", "")).strip().lower()
|
|
if relevance not in RELEVANCE_VALUES:
|
|
relevance = fallback["relevance"]
|
|
|
|
problem = str(analysis.get("problem", "")).strip().lower()
|
|
problem = PROBLEM_ALIASES.get(problem, problem)
|
|
if problem not in PROBLEM_VALUES:
|
|
problem = fallback["problem"]
|
|
|
|
reason = str(analysis.get("reason", "")).strip() or fallback["reason"]
|
|
source = str(analysis.get("source", "")).strip().lower()
|
|
if source not in {"model", "heuristic"}:
|
|
source = "model" if analysis else "heuristic"
|
|
return {
|
|
"arxiv_id": record["arxiv_id"],
|
|
"relevance": relevance,
|
|
"problem": problem,
|
|
"reason": reason,
|
|
"source": source,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--input",
|
|
type=Path,
|
|
default=ROOT / "data" / "research" / "agent-memory-expanded.json",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=ROOT / "data" / "research" / "agent-memory-screened.json",
|
|
)
|
|
parser.add_argument("--ollama-url", default="http://192.168.1.10:11434")
|
|
parser.add_argument("--model", default="ChatGPT-5.6:Luna")
|
|
parser.add_argument("--batch-size", type=int, default=24)
|
|
parser.add_argument("--timeout", type=int, default=180)
|
|
parser.add_argument("--retries", type=int, default=2)
|
|
parser.add_argument("--refresh", action="store_true")
|
|
args = parser.parse_args()
|
|
args.input = args.input.resolve()
|
|
args.output = args.output.resolve()
|
|
|
|
source = json.loads(args.input.read_text(encoding="utf-8"))
|
|
records = source["papers"]
|
|
completed: dict[str, dict] = {}
|
|
if args.output.exists() and not args.refresh:
|
|
previous = json.loads(args.output.read_text(encoding="utf-8"))
|
|
completed = {
|
|
item["arxiv_id"]: {
|
|
**item,
|
|
"screen": normalize_analysis(item, item.get("screen")),
|
|
}
|
|
for item in previous.get("papers", [])
|
|
}
|
|
|
|
pending = [item for item in records if item["arxiv_id"] not in completed]
|
|
for offset in range(0, len(pending), args.batch_size):
|
|
batch = pending[offset : offset + args.batch_size]
|
|
try:
|
|
screened = generate(
|
|
args.ollama_url, args.model, batch, timeout=args.timeout, retries=args.retries
|
|
)
|
|
except Exception:
|
|
screened = []
|
|
for record in batch:
|
|
try:
|
|
screened.extend(
|
|
generate(
|
|
args.ollama_url,
|
|
args.model,
|
|
[record],
|
|
timeout=args.timeout,
|
|
retries=args.retries,
|
|
)
|
|
)
|
|
except Exception:
|
|
screened.append(heuristic_screen(record))
|
|
for record, analysis in zip(batch, screened, strict=True):
|
|
completed[record["arxiv_id"]] = {
|
|
**record,
|
|
"screen": normalize_analysis(record, analysis),
|
|
}
|
|
ordered = [completed[item["arxiv_id"]] for item in records if item["arxiv_id"] in completed]
|
|
write_json(
|
|
args.output,
|
|
{
|
|
"generated_at": date.today().isoformat(),
|
|
"model": args.model,
|
|
"source": str(args.input.relative_to(ROOT)),
|
|
"total": len(records),
|
|
"completed": len(ordered),
|
|
"papers": ordered,
|
|
},
|
|
)
|
|
print(f"screened {len(ordered)}/{len(records)}", flush=True)
|
|
|
|
# Rewrites an already complete file after schema normalization as well.
|
|
ordered = [completed[item["arxiv_id"]] for item in records if item["arxiv_id"] in completed]
|
|
write_json(
|
|
args.output,
|
|
{
|
|
"generated_at": date.today().isoformat(),
|
|
"model": args.model,
|
|
"source": str(args.input.relative_to(ROOT)),
|
|
"total": len(records),
|
|
"completed": len(ordered),
|
|
"papers": ordered,
|
|
},
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|