Audit recent Agent memory research
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build auditable landscape counts and evidence candidates for Agent Memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
THEMES = {
|
||||
"retrieval_access": r"retriev|recall|search|query|evidence access|memory navigation",
|
||||
"representation_organization": r"knowledge graph|graph memory|hierarch|structure|representation|index|database|topic document|ontology",
|
||||
"update_forgetting": r"update|forget|consolidat|supersed|stale|conflict|write path|write-path|memory admission|retention",
|
||||
"execution_state": r"execution state|task state|workflow state|belief state|long-horizon|trajectory|subgoal|planning",
|
||||
"experience_skill_learning": r"experience|procedural|skill|reflect|self-evolv|test-time learn|continual learn|reinforcement learn|\brl\b",
|
||||
"evaluation_diagnosis": r"benchmark|evaluat|diagnos|metric|ablation|empirical study|controlled",
|
||||
"security_privacy_trust": r"attack|poison|security|privacy|trust|provenance|govern|leak|integrity|adversarial",
|
||||
"multi_agent_shared": r"multi-agent|multiagent|shared memory|collective memory|collaborative memory",
|
||||
"multimodal_embodied_gui": r"multimodal|visual|video|robot|embodied|gui|mobile|web agent|navigation",
|
||||
"systems_efficiency": r"token|latency|cost|efficient|compression|serving|storage|throughput|budget",
|
||||
}
|
||||
|
||||
# Title-only labels describe the paper's declared contribution more conservatively
|
||||
# than abstract keywords such as "evaluate", which occur in almost every paper.
|
||||
TITLE_THEMES = {
|
||||
"benchmark_diagnosis": r"benchmark|evaluat|diagnos|empirical|survey|taxonomy|anatomy|rethinking|what .* need|how far|study of|analysis",
|
||||
"retrieval_representation": r"retriev|recall|search|graph|hierarch|structure|index|database|ontology|organization|representation|rag\b|topic document",
|
||||
"update_forgetting": r"update|forget|consolidat|stale|supersed|decay|write memory|not to write|promotion|temporal conflict|belief revision|lifecycle",
|
||||
"experience_skill_policy": r"experience|skill|procedural|learn|evolv|adapt|reinforcement|\brl\b|credit assignment|distill",
|
||||
"execution_multimodal": r"execution state|task-driving state|task state|long-horizon|gui|embodied|robot|navigation|web agent|multimodal|video|spatial",
|
||||
"security_governance": r"attack|poison|security|privacy|trust|govern|provenance|integrity|leak|hijack|vulnerab|unlearn|false promotion|contamination",
|
||||
"systems_efficiency": r"efficient|cost|token|latency|serving|on-device|edge|compress|scalable|storage|runtime|budget|wire format",
|
||||
"personal_shared": r"personali|user-aware|user-centric|multi-agent|multiagent|shared memory|collaborative|collective",
|
||||
}
|
||||
|
||||
WINDOWS = {
|
||||
"previous_six_months": ("2025-07-09", "2026-01-10"),
|
||||
"recent_six_months": ("2026-01-10", "2026-07-11"),
|
||||
}
|
||||
|
||||
CENTRAL_TITLE = re.compile(r"memory|memori|remember|forget|stateful", re.I)
|
||||
AGENT_CONTEXT = re.compile(r"agent|large language model|\bllm\b|foundation model", re.I)
|
||||
HARDWARE_ONLY = re.compile(
|
||||
r"gpu memory|memory bandwidth|memory footprint|memory-efficient|memory efficient|bounded memory agents on",
|
||||
re.I,
|
||||
)
|
||||
NUMERIC_RESULT = re.compile(r"(?:\d+(?:\.\d+)?\s*%|\+?\d+(?:\.\d+)?\s*(?:pp|points?|×|x))", re.I)
|
||||
|
||||
|
||||
def period(published: str) -> str:
|
||||
if published < "2025-07-01":
|
||||
return "2025-H1"
|
||||
if published < "2026-02-01":
|
||||
return "baseline-2025H2-Jan2026"
|
||||
if published < "2026-04-01":
|
||||
return "transition-2026-Feb-Mar"
|
||||
return "recent-2026-Apr-Jul"
|
||||
|
||||
|
||||
def central_by_text(record: dict) -> bool:
|
||||
title = record["title"]
|
||||
abstract = record["abstract"]
|
||||
return bool(
|
||||
CENTRAL_TITLE.search(title)
|
||||
and AGENT_CONTEXT.search(f"{title} {abstract}")
|
||||
and not HARDWARE_ONLY.search(title)
|
||||
)
|
||||
|
||||
|
||||
def theme_labels(record: dict) -> list[str]:
|
||||
text = f"{record['title']} {record['abstract']}".lower()
|
||||
return [name for name, pattern in THEMES.items() if re.search(pattern, text, re.I)]
|
||||
|
||||
|
||||
def title_theme_labels(record: dict) -> list[str]:
|
||||
title = record["title"].lower()
|
||||
return [name for name, pattern in TITLE_THEMES.items() if re.search(pattern, title, re.I)]
|
||||
|
||||
|
||||
def window_name(published: str) -> str | None:
|
||||
for name, (start, end) in WINDOWS.items():
|
||||
if start <= published < end:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def evidence_score(record: dict) -> int:
|
||||
text = record["abstract"].lower()
|
||||
score = 0
|
||||
score += 2 if re.search(r"benchmark|evaluate|evaluation|experiment", text) else 0
|
||||
score += 2 if re.search(r"baseline|compare|outperform|versus|\bvs\.?\b", text) else 0
|
||||
score += 2 if NUMERIC_RESULT.search(text) else 0
|
||||
score += 1 if re.search(r"ablation|controlled|factorial|paired|significan", text) else 0
|
||||
score += 1 if re.search(r"code is|github|we release|open-source", text) else 0
|
||||
score += 1 if re.search(r"limitation|does not|not always|far from|fails?", text) else 0
|
||||
return score
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
type=Path,
|
||||
default=ROOT / "data" / "research" / "agent-memory-expanded.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screen",
|
||||
type=Path,
|
||||
default=ROOT / "data" / "research" / "agent-memory-screened.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=ROOT / "data" / "research" / "memory-landscape.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
type=Path,
|
||||
default=ROOT / "data" / "research" / "memory-paper-ledger.csv",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
screen = {}
|
||||
if args.screen.exists():
|
||||
screened = json.loads(args.screen.read_text(encoding="utf-8"))
|
||||
screen = {item["arxiv_id"]: item.get("screen") or {} for item in screened.get("papers", [])}
|
||||
|
||||
records = []
|
||||
for paper in source["papers"]:
|
||||
screened = screen.get(paper["arxiv_id"])
|
||||
records.append(
|
||||
{
|
||||
**paper,
|
||||
"period": period(paper["published"]),
|
||||
"central_by_title": central_by_text(paper),
|
||||
"themes": theme_labels(paper),
|
||||
"title_themes": title_theme_labels(paper),
|
||||
"evidence_score": evidence_score(paper),
|
||||
"screen": screened,
|
||||
}
|
||||
)
|
||||
|
||||
period_counts: defaultdict[str, Counter] = defaultdict(Counter)
|
||||
theme_counts: defaultdict[str, Counter] = defaultdict(Counter)
|
||||
window_counts: defaultdict[str, Counter] = defaultdict(Counter)
|
||||
title_theme_counts: defaultdict[str, Counter] = defaultdict(Counter)
|
||||
screen_counts: defaultdict[str, Counter] = defaultdict(Counter)
|
||||
for record in records:
|
||||
bucket = record["period"]
|
||||
period_counts[bucket]["candidates"] += 1
|
||||
if record["central_by_title"]:
|
||||
period_counts[bucket]["title_central"] += 1
|
||||
relevance = (record.get("screen") or {}).get("relevance")
|
||||
if relevance:
|
||||
period_counts[bucket][f"screen_{relevance}"] += 1
|
||||
for theme in record["themes"]:
|
||||
if record["central_by_title"]:
|
||||
theme_counts[bucket][theme] += 1
|
||||
window = window_name(record["published"])
|
||||
if window:
|
||||
window_counts[window]["candidates"] += 1
|
||||
if record["central_by_title"]:
|
||||
window_counts[window]["title_central"] += 1
|
||||
for theme in record["title_themes"]:
|
||||
title_theme_counts[window][theme] += 1
|
||||
screened = record.get("screen") or {}
|
||||
if screened:
|
||||
screen_counts["relevance"][screened.get("relevance", "unknown")] += 1
|
||||
screen_counts["problem"][screened.get("problem", "unknown")] += 1
|
||||
screen_counts["source"][screened.get("source", "unknown")] += 1
|
||||
|
||||
top_evidence = {}
|
||||
for theme in THEMES:
|
||||
candidates = [
|
||||
record
|
||||
for record in records
|
||||
if record["central_by_title"] and theme in record["themes"]
|
||||
]
|
||||
candidates.sort(key=lambda item: (item["evidence_score"], item["published"]), reverse=True)
|
||||
top_evidence[theme] = [
|
||||
{
|
||||
"arxiv_id": item["arxiv_id"],
|
||||
"published": item["published"],
|
||||
"title": item["title"],
|
||||
"score": item["evidence_score"],
|
||||
"url": item["url"],
|
||||
}
|
||||
for item in candidates[:20]
|
||||
]
|
||||
|
||||
output = {
|
||||
"generated_at": date.today().isoformat(),
|
||||
"source_candidates": len(records),
|
||||
"title_central": sum(record["central_by_title"] for record in records),
|
||||
"screened": len(screen),
|
||||
"windows": {
|
||||
name: {
|
||||
"from_inclusive": start,
|
||||
"to_exclusive": end,
|
||||
**dict(window_counts[name]),
|
||||
"title_theme_counts": dict(title_theme_counts[name]),
|
||||
}
|
||||
for name, (start, end) in WINDOWS.items()
|
||||
},
|
||||
"period_counts": {key: dict(value) for key, value in period_counts.items()},
|
||||
"theme_counts_title_central": {key: dict(value) for key, value in theme_counts.items()},
|
||||
"screen_counts": {key: dict(value) for key, value in screen_counts.items()},
|
||||
"evidence_score_distribution": dict(
|
||||
Counter(record["evidence_score"] for record in records if record["central_by_title"])
|
||||
),
|
||||
"top_evidence_candidates": top_evidence,
|
||||
}
|
||||
write_json(args.output, output)
|
||||
|
||||
args.csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.csv.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=[
|
||||
"arxiv_id",
|
||||
"published",
|
||||
"period",
|
||||
"title",
|
||||
"url",
|
||||
"title_central",
|
||||
"screen_relevance",
|
||||
"screen_problem",
|
||||
"screen_source",
|
||||
"themes",
|
||||
"title_themes",
|
||||
"evidence_score",
|
||||
"matched_queries",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
for record in records:
|
||||
screened = record.get("screen") or {}
|
||||
writer.writerow(
|
||||
{
|
||||
"arxiv_id": record["arxiv_id"],
|
||||
"published": record["published"],
|
||||
"period": record["period"],
|
||||
"title": record["title"],
|
||||
"url": record["url"],
|
||||
"title_central": record["central_by_title"],
|
||||
"screen_relevance": screened.get("relevance", ""),
|
||||
"screen_problem": screened.get("problem", ""),
|
||||
"screen_source": screened.get("source", ""),
|
||||
"themes": ";".join(record["themes"]),
|
||||
"title_themes": ";".join(record["title_themes"]),
|
||||
"evidence_score": record["evidence_score"],
|
||||
"matched_queries": ";".join(record["matched_queries"]),
|
||||
}
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"source_candidates": output["source_candidates"],
|
||||
"title_central": output["title_central"],
|
||||
"screened": output["screened"],
|
||||
"windows": output["windows"],
|
||||
"screen_counts": output["screen_counts"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify an Agent-memory abstract corpus into a research evidence ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_OLLAMA_URL = "http://192.168.1.10:11434"
|
||||
|
||||
SYSTEM_INSTRUCTIONS = """You are building an evidence ledger for a research review of Agent Memory.
|
||||
Classify each paper from title and abstract only. Be conservative. Do not infer unreported baselines,
|
||||
results, limitations, or artifacts. A paper is core only when memory itself is the main research object;
|
||||
supporting means memory is a substantial mechanism or measured failure; peripheral means memory is
|
||||
incidental or only an implementation detail.
|
||||
|
||||
Return one JSON object with a `papers` array in the same order. Each item must contain:
|
||||
- arxiv_id: exact input id
|
||||
- relevance: core | supporting | peripheral
|
||||
- relevance_reason: <= 18 words
|
||||
- primary_problem: one of retention_context, retrieval_access, organization_representation,
|
||||
update_consolidation_forgetting, execution_state, experience_skill_learning,
|
||||
evaluation_measurement, security_trust_privacy, multi_agent_shared_memory,
|
||||
systems_efficiency, domain_application, theory_foundations, other
|
||||
- secondary_problems: up to 3 values from the same enum
|
||||
- research_role: method | benchmark | diagnostic | theory | survey | security | application | infrastructure
|
||||
- memory_object: facts | events | trajectories | skills | execution_state | model_internal |
|
||||
shared_state | multimodal | mixed | unclear
|
||||
- temporal_scope: in_episode | cross_episode | both | unclear
|
||||
- claimed_gap: one short sentence, or empty
|
||||
- mechanism: one short sentence, or empty
|
||||
- benchmarks: explicit names only, as an array
|
||||
- baselines: explicit names or baseline families only, as an array
|
||||
- reported_results: explicit numeric claims only, as an array of short strings
|
||||
- evidence_design: none | case_study | benchmark_comparison | controlled_ablation |
|
||||
formal_analysis | survey_synthesis
|
||||
- evidence_strength: none | weak | moderate | strong
|
||||
- failure_or_boundary: only a boundary explicitly visible in the abstract, or empty
|
||||
|
||||
Use compact English. Output JSON only."""
|
||||
|
||||
|
||||
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 ollama_generate(url: str, model: str, prompt: str, timeout: int, retries: int) -> dict:
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0, "num_predict": 6000},
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{url.rstrip('/')}/api/generate",
|
||||
data=json.dumps(payload).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:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
return json.loads(body["response"])
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||||
if attempt >= retries:
|
||||
raise
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError("unreachable retry state")
|
||||
|
||||
|
||||
def classify_batch(
|
||||
records: list[dict], url: str, model: str, timeout: int, retries: int
|
||||
) -> list[dict]:
|
||||
compact = [
|
||||
{"arxiv_id": item["arxiv_id"], "title": item["title"], "abstract": item["abstract"]}
|
||||
for item in records
|
||||
]
|
||||
prompt = f"{SYSTEM_INSTRUCTIONS}\n\nINPUT:\n{json.dumps(compact, ensure_ascii=False)}"
|
||||
result = ollama_generate(url, model, prompt, timeout=timeout, retries=retries)
|
||||
classified = result.get("papers") if isinstance(result, dict) else None
|
||||
if not isinstance(classified, list):
|
||||
raise ValueError("model response lacks papers array")
|
||||
expected = [item["arxiv_id"] for item in records]
|
||||
received = [str(item.get("arxiv_id")) for item in classified]
|
||||
if received != expected:
|
||||
raise ValueError(f"model returned unexpected ids: expected={expected}, received={received}")
|
||||
return classified
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--input", type=Path, default=ROOT / "data" / "research" / "memory-corpus.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=ROOT / "data" / "research" / "memory-classified.json"
|
||||
)
|
||||
parser.add_argument("--ollama-url", default=DEFAULT_OLLAMA_URL)
|
||||
parser.add_argument("--model", default="ChatGPT-5.6:Sol")
|
||||
parser.add_argument("--batch-size", type=int, default=12)
|
||||
parser.add_argument("--timeout", type=int, default=300)
|
||||
parser.add_argument("--retries", type=int, default=2)
|
||||
parser.add_argument("--limit", type=int)
|
||||
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"][: args.limit] if args.limit else source["papers"]
|
||||
completed: dict[str, dict] = {}
|
||||
if args.output.exists() and not args.refresh:
|
||||
old = json.loads(args.output.read_text(encoding="utf-8"))
|
||||
completed = {item["arxiv_id"]: item for item in old.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:
|
||||
classified = classify_batch(
|
||||
batch,
|
||||
url=args.ollama_url,
|
||||
model=args.model,
|
||||
timeout=args.timeout,
|
||||
retries=args.retries,
|
||||
)
|
||||
except Exception:
|
||||
if len(batch) == 1:
|
||||
raise
|
||||
classified = []
|
||||
for record in batch:
|
||||
classified.extend(
|
||||
classify_batch(
|
||||
[record],
|
||||
url=args.ollama_url,
|
||||
model=args.model,
|
||||
timeout=args.timeout,
|
||||
retries=args.retries,
|
||||
)
|
||||
)
|
||||
for source_record, classification in zip(batch, classified, strict=True):
|
||||
completed[source_record["arxiv_id"]] = {**source_record, "analysis": classification}
|
||||
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)) if args.input.is_relative_to(ROOT) else str(args.input),
|
||||
"total": len(records),
|
||||
"completed": len(ordered),
|
||||
"papers": ordered,
|
||||
},
|
||||
)
|
||||
print(f"classified {len(ordered)}/{len(records)}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an abstract-level research corpus for one local paper topic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
API_URL = "https://export.arxiv.org/api/query"
|
||||
NS = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
ARXIV_ID_RE = re.compile(r"arxiv\.org/(?:abs|html|pdf)/([0-9]{4}\.[0-9]{4,5})")
|
||||
|
||||
|
||||
def normalize(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def extract_arxiv_id(url: str) -> str | None:
|
||||
match = ARXIV_ID_RE.search(url or "")
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def parse_feed(payload: bytes) -> dict[str, dict]:
|
||||
root = ET.fromstring(payload)
|
||||
records: dict[str, dict] = {}
|
||||
for entry in root.findall("atom:entry", NS):
|
||||
raw_id = entry.findtext("atom:id", default="", namespaces=NS)
|
||||
paper_id = raw_id.rsplit("/", 1)[-1].split("v", 1)[0]
|
||||
records[paper_id] = {
|
||||
"arxiv_id": paper_id,
|
||||
"title": normalize(entry.findtext("atom:title", default="", namespaces=NS)),
|
||||
"abstract": normalize(entry.findtext("atom:summary", default="", namespaces=NS)),
|
||||
"published": entry.findtext("atom:published", default="", namespaces=NS)[:10],
|
||||
"updated": entry.findtext("atom:updated", default="", namespaces=NS)[:10],
|
||||
"authors": [
|
||||
normalize(author.findtext("atom:name", default="", namespaces=NS))
|
||||
for author in entry.findall("atom:author", NS)
|
||||
],
|
||||
"categories": [
|
||||
node.attrib["term"]
|
||||
for node in entry.findall("atom:category", NS)
|
||||
if node.attrib.get("term")
|
||||
],
|
||||
"url": f"https://arxiv.org/abs/{paper_id}",
|
||||
"pdf_url": f"https://arxiv.org/pdf/{paper_id}",
|
||||
}
|
||||
return records
|
||||
|
||||
|
||||
def fetch_batch(ids: list[str], retries: int, timeout: int) -> dict[str, dict]:
|
||||
query = urllib.parse.urlencode({"id_list": ",".join(ids), "max_results": len(ids)})
|
||||
request = urllib.request.Request(
|
||||
f"{API_URL}?{query}",
|
||||
headers={"User-Agent": "agent-kb-research/0.1"},
|
||||
)
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return parse_feed(response.read())
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
|
||||
if attempt >= retries:
|
||||
raise
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError("unreachable retry state")
|
||||
|
||||
|
||||
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 main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--topic", default="memory")
|
||||
parser.add_argument("--index", type=Path, default=ROOT / "data" / "index.json")
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--batch-size", type=int, default=20)
|
||||
parser.add_argument("--sleep", type=float, default=1.0)
|
||||
parser.add_argument("--retries", type=int, default=4)
|
||||
parser.add_argument("--timeout", type=int, default=45)
|
||||
parser.add_argument("--refresh", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
output = args.output or ROOT / "data" / "research" / f"{args.topic}-corpus.json"
|
||||
index = json.loads(args.index.read_text(encoding="utf-8"))
|
||||
selected: dict[str, dict] = {}
|
||||
missing_ids: list[dict] = []
|
||||
for item in index:
|
||||
meta = item.get("meta") or {}
|
||||
if item.get("collection") != "papers" or args.topic not in (meta.get("topics") or []):
|
||||
continue
|
||||
paper_id = extract_arxiv_id(str(meta.get("url") or ""))
|
||||
if not paper_id:
|
||||
missing_ids.append({"path": item.get("path"), "title": item.get("title")})
|
||||
continue
|
||||
selected[paper_id] = {
|
||||
"path": item.get("path"),
|
||||
"local_title": item.get("title"),
|
||||
"local_status": meta.get("status"),
|
||||
"local_topics": meta.get("topics") or [],
|
||||
"collection_queries": meta.get("collection_queries"),
|
||||
"collection_score": meta.get("collection_score"),
|
||||
}
|
||||
|
||||
cached: dict[str, dict] = {}
|
||||
if output.exists() and not args.refresh:
|
||||
old = json.loads(output.read_text(encoding="utf-8"))
|
||||
cached = {record["arxiv_id"]: record for record in old.get("papers", [])}
|
||||
|
||||
fetched: dict[str, dict] = {}
|
||||
ids_to_fetch = [paper_id for paper_id in selected if paper_id not in cached]
|
||||
for offset in range(0, len(ids_to_fetch), args.batch_size):
|
||||
batch = ids_to_fetch[offset : offset + args.batch_size]
|
||||
fetched.update(fetch_batch(batch, retries=args.retries, timeout=args.timeout))
|
||||
print(f"fetched {min(offset + len(batch), len(ids_to_fetch))}/{len(ids_to_fetch)}")
|
||||
if offset + args.batch_size < len(ids_to_fetch):
|
||||
time.sleep(args.sleep)
|
||||
|
||||
papers = []
|
||||
unresolved = []
|
||||
for paper_id, local in selected.items():
|
||||
remote = fetched.get(paper_id) or cached.get(paper_id)
|
||||
if not remote:
|
||||
unresolved.append({"arxiv_id": paper_id, **local})
|
||||
continue
|
||||
papers.append({**remote, **local})
|
||||
papers.sort(key=lambda item: (item.get("published") or "", item["arxiv_id"]))
|
||||
|
||||
payload = {
|
||||
"topic": args.topic,
|
||||
"generated_at": date.today().isoformat(),
|
||||
"selected_from_index": len(selected),
|
||||
"resolved": len(papers),
|
||||
"missing_arxiv_id": missing_ids,
|
||||
"unresolved": unresolved,
|
||||
"papers": papers,
|
||||
}
|
||||
write_json(output, payload)
|
||||
print(json.dumps({key: payload[key] for key in ("selected_from_index", "resolved")}, indent=2))
|
||||
return 0 if not unresolved else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect a paginated, abstract-level Agent-memory research corpus from arXiv."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
API_URL = "https://export.arxiv.org/api/query"
|
||||
NS = {
|
||||
"atom": "http://www.w3.org/2005/Atom",
|
||||
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
|
||||
}
|
||||
|
||||
QUERIES = [
|
||||
("agent-memory", 'all:"agent memory" OR all:"memory agent" OR all:"memory agents"'),
|
||||
(
|
||||
"long-term-memory",
|
||||
'(all:"long-term memory" OR all:"persistent memory") AND '
|
||||
'(all:"LLM agent" OR all:"language agent" OR all:"large language model agent")',
|
||||
),
|
||||
(
|
||||
"episodic-memory",
|
||||
'all:"episodic memory" AND (all:"LLM" OR all:"large language model") AND all:"agent"',
|
||||
),
|
||||
(
|
||||
"procedural-memory",
|
||||
'(all:"procedural memory" OR all:"skill memory") AND '
|
||||
'(all:"LLM agent" OR all:"language agent")',
|
||||
),
|
||||
(
|
||||
"memory-evaluation",
|
||||
'(all:"memory benchmark" OR all:"memory evaluation") AND '
|
||||
'(all:"LLM agent" OR all:"language agent" OR all:"agent memory")',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def normalize(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def arxiv_id(raw: str) -> str:
|
||||
return raw.rsplit("/", 1)[-1].split("v", 1)[0]
|
||||
|
||||
|
||||
def date_query(query: str, start: str, end: str) -> str:
|
||||
return (
|
||||
f"({query}) AND submittedDate:[{start.replace('-', '')}0000 "
|
||||
f"TO {end.replace('-', '')}2359]"
|
||||
)
|
||||
|
||||
|
||||
def fetch_page(
|
||||
query: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
offset: int,
|
||||
page_size: int,
|
||||
retries: int,
|
||||
timeout: int,
|
||||
) -> bytes:
|
||||
params = {
|
||||
"search_query": date_query(query, start_date, end_date),
|
||||
"start": offset,
|
||||
"max_results": page_size,
|
||||
"sortBy": "submittedDate",
|
||||
"sortOrder": "ascending",
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{API_URL}?{urllib.parse.urlencode(params)}",
|
||||
headers={"User-Agent": "agent-kb-research/0.1"},
|
||||
)
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
|
||||
if attempt >= retries:
|
||||
raise
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError("unreachable retry state")
|
||||
|
||||
|
||||
def parse_page(payload: bytes) -> tuple[int, list[dict]]:
|
||||
root = ET.fromstring(payload)
|
||||
total = int(root.findtext("opensearch:totalResults", default="0", namespaces=NS))
|
||||
records = []
|
||||
for entry in root.findall("atom:entry", NS):
|
||||
paper_id = arxiv_id(entry.findtext("atom:id", default="", namespaces=NS))
|
||||
records.append(
|
||||
{
|
||||
"arxiv_id": paper_id,
|
||||
"title": normalize(entry.findtext("atom:title", default="", namespaces=NS)),
|
||||
"abstract": normalize(entry.findtext("atom:summary", default="", namespaces=NS)),
|
||||
"published": entry.findtext("atom:published", default="", namespaces=NS)[:10],
|
||||
"updated": entry.findtext("atom:updated", default="", namespaces=NS)[:10],
|
||||
"authors": [
|
||||
normalize(author.findtext("atom:name", default="", namespaces=NS))
|
||||
for author in entry.findall("atom:author", NS)
|
||||
],
|
||||
"categories": [
|
||||
node.attrib["term"]
|
||||
for node in entry.findall("atom:category", NS)
|
||||
if node.attrib.get("term")
|
||||
],
|
||||
"url": f"https://arxiv.org/abs/{paper_id}",
|
||||
"pdf_url": f"https://arxiv.org/pdf/{paper_id}",
|
||||
}
|
||||
)
|
||||
return total, records
|
||||
|
||||
|
||||
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 main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--from-date", default="2025-01-01")
|
||||
parser.add_argument("--to-date", default=date.today().isoformat())
|
||||
parser.add_argument("--page-size", type=int, default=100)
|
||||
parser.add_argument("--max-per-query", type=int, default=2000)
|
||||
parser.add_argument("--sleep", type=float, default=1.0)
|
||||
parser.add_argument("--retries", type=int, default=4)
|
||||
parser.add_argument("--timeout", type=int, default=45)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=ROOT / "data" / "research" / "agent-memory-expanded.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
records: dict[str, dict] = {}
|
||||
matched_queries: defaultdict[str, set[str]] = defaultdict(set)
|
||||
query_totals = {}
|
||||
for name, query in QUERIES:
|
||||
offset = 0
|
||||
while offset < args.max_per_query:
|
||||
payload = fetch_page(
|
||||
query,
|
||||
args.from_date,
|
||||
args.to_date,
|
||||
offset,
|
||||
args.page_size,
|
||||
args.retries,
|
||||
args.timeout,
|
||||
)
|
||||
total, page = parse_page(payload)
|
||||
query_totals[name] = total
|
||||
for record in page:
|
||||
records[record["arxiv_id"]] = record
|
||||
matched_queries[record["arxiv_id"]].add(name)
|
||||
offset += len(page)
|
||||
print(f"{name}: {min(offset, total)}/{total}", flush=True)
|
||||
if not page or offset >= total:
|
||||
break
|
||||
time.sleep(args.sleep)
|
||||
time.sleep(args.sleep)
|
||||
|
||||
papers = [
|
||||
{**record, "matched_queries": sorted(matched_queries[paper_id])}
|
||||
for paper_id, record in records.items()
|
||||
]
|
||||
papers.sort(key=lambda item: (item["published"], item["arxiv_id"]))
|
||||
output = {
|
||||
"generated_at": date.today().isoformat(),
|
||||
"from_date": args.from_date,
|
||||
"to_date": args.to_date,
|
||||
"query_totals": query_totals,
|
||||
"unique_papers": len(papers),
|
||||
"papers": papers,
|
||||
}
|
||||
write_json(args.output, output)
|
||||
print(json.dumps({"query_totals": query_totals, "unique_papers": len(papers)}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user