Files
AOS/skills/aminer-deep-search/scripts/paper_set.py
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

177 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cross-round paper-set state file for aminer-deep-search (no network access).
Hundreds of collected papers do not fit in the host model's context, so this
CLI keeps them in a JSON state file and deduplicates by AMiner paper ID.
Subcommands (stdout is always a single JSON document):
add merge papers from stdin (JSON array) or --ids into the state file
stats totals, expanded seeds, and year distribution
export write the final [{"id","title"}] list and report path + count
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
DEFAULT_STATE_FILE = "outputs/paper_set.json"
def _load_state(path: Path) -> dict[str, Any]:
if not path.exists():
return {"papers": {}, "expanded_seeds": []}
with path.open("r", encoding="utf-8") as file:
state = json.load(file)
state.setdefault("papers", {})
state.setdefault("expanded_seeds", [])
return state
def _save_state(path: Path, state: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
json.dump(state, file, ensure_ascii=False, indent=2)
def _normalize_items(raw_items: Any) -> list[dict[str, Any]]:
if not isinstance(raw_items, list):
raise ValueError("input must be a JSON array of papers")
items: list[dict[str, Any]] = []
for raw in raw_items:
if isinstance(raw, str):
items.append({"id": raw.strip()})
elif isinstance(raw, dict):
items.append(raw)
return items
def cmd_add(args: argparse.Namespace) -> dict[str, Any]:
if args.ids:
items = [{"id": paper_id} for paper_id in args.ids]
else:
stdin_text = sys.stdin.read().strip()
if not stdin_text:
raise ValueError("no input: pipe a JSON array to stdin or pass --ids")
items = _normalize_items(json.loads(stdin_text))
state_path = Path(args.file)
state = _load_state(state_path)
papers: dict[str, dict[str, Any]] = state["papers"]
expanded: set[str] = set(state["expanded_seeds"])
added = 0
duplicates = 0
for item in items:
paper_id = str(item.get("id") or item.get("_id") or "").strip()
if not paper_id:
continue
# source_paper_ids on an item means those seeds have been expanded
for seed_id in item.get("source_paper_ids") or []:
expanded.add(str(seed_id))
if paper_id in papers:
duplicates += 1
existing = papers[paper_id]
for key, value in item.items():
if key != "source_paper_ids" and value not in (None, "", []):
existing.setdefault(key, value)
else:
added += 1
papers[paper_id] = {
key: value for key, value in item.items()
if key != "source_paper_ids" and value not in (None, "", [])
}
state["expanded_seeds"] = sorted(expanded)
_save_state(state_path, state)
return {"added": added, "duplicates": duplicates, "total": len(papers)}
def cmd_mark_expanded(args: argparse.Namespace) -> dict[str, Any]:
state_path = Path(args.file)
state = _load_state(state_path)
expanded = set(state["expanded_seeds"])
expanded.update(str(paper_id).strip() for paper_id in args.ids if str(paper_id).strip())
state["expanded_seeds"] = sorted(expanded)
_save_state(state_path, state)
return {"expanded_seeds": state["expanded_seeds"]}
def cmd_stats(args: argparse.Namespace) -> dict[str, Any]:
state = _load_state(Path(args.file))
papers = state["papers"]
by_year: dict[str, int] = {}
missing_title = 0
for paper in papers.values():
year = paper.get("year")
key = str(year) if year else "unknown"
by_year[key] = by_year.get(key, 0) + 1
if not paper.get("title"):
missing_title += 1
return {
"total": len(papers),
"expanded_seeds": state["expanded_seeds"],
"missing_title": missing_title,
"by_year": dict(sorted(by_year.items(), reverse=True)),
}
def cmd_export(args: argparse.Namespace) -> dict[str, Any]:
state = _load_state(Path(args.file))
final = [
{"id": paper_id, "title": paper.get("title", "")}
for paper_id, paper in state["papers"].items()
if paper.get("title")
]
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as file:
json.dump(final, file, ensure_ascii=False, indent=2)
return {"count": len(final), "path": str(out_path)}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Deduplicated paper-set state file (stdout: JSON only, no network)."
)
parser.add_argument("--file", default=DEFAULT_STATE_FILE,
help=f"State file path (default {DEFAULT_STATE_FILE}).")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="Merge papers from stdin JSON array or --ids.")
p_add.add_argument("--ids", nargs="*", default=None,
help="Add bare paper IDs instead of reading stdin.")
p_add.set_defaults(func=cmd_add)
p_mark = sub.add_parser("mark-expanded", help="Record seed IDs whose references were expanded.")
p_mark.add_argument("--ids", nargs="+", required=True)
p_mark.set_defaults(func=cmd_mark_expanded)
p_stats = sub.add_parser("stats", help="Show totals, expanded seeds, and year distribution.")
p_stats.set_defaults(func=cmd_stats)
p_export = sub.add_parser("export", help="Write the final [{id,title}] list.")
p_export.add_argument("-o", "--output", default="outputs/final_papers.json")
p_export.set_defaults(func=cmd_export)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = args.func(args)
except (ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": "invalid_input", "message": str(exc)},
ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())