priorwork 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. priorwork/__init__.py +3 -0
  2. priorwork/__main__.py +3 -0
  3. priorwork/api.py +496 -0
  4. priorwork/assets/en/AGENTS.md +90 -0
  5. priorwork/assets/en/skills/survey-check/SKILL.md +47 -0
  6. priorwork/assets/en/skills/survey-extract/SKILL.md +52 -0
  7. priorwork/assets/en/skills/survey-new/SKILL.md +58 -0
  8. priorwork/assets/en/skills/survey-screen/SKILL.md +56 -0
  9. priorwork/assets/en/skills/survey-snowball/SKILL.md +33 -0
  10. priorwork/assets/en/templates/literature_review.md +71 -0
  11. priorwork/assets/env.example +35 -0
  12. priorwork/assets/ja/AGENTS.md +90 -0
  13. priorwork/assets/ja/skills/survey-check/SKILL.md +47 -0
  14. priorwork/assets/ja/skills/survey-extract/SKILL.md +52 -0
  15. priorwork/assets/ja/skills/survey-new/SKILL.md +58 -0
  16. priorwork/assets/ja/skills/survey-screen/SKILL.md +56 -0
  17. priorwork/assets/ja/skills/survey-snowball/SKILL.md +33 -0
  18. priorwork/assets/ja/templates/literature_review.md +71 -0
  19. priorwork/check.py +234 -0
  20. priorwork/cli.py +776 -0
  21. priorwork/doctor.py +149 -0
  22. priorwork/export.py +236 -0
  23. priorwork/fulltext.py +188 -0
  24. priorwork/i18n.py +98 -0
  25. priorwork/lang_ja.py +531 -0
  26. priorwork/scaffold.py +463 -0
  27. priorwork/snowball.py +60 -0
  28. priorwork/ssci.py +231 -0
  29. priorwork/survey.py +706 -0
  30. priorwork/workspace.py +100 -0
  31. priorwork/zotero.py +267 -0
  32. priorwork-0.1.0.dist-info/METADATA +447 -0
  33. priorwork-0.1.0.dist-info/RECORD +37 -0
  34. priorwork-0.1.0.dist-info/WHEEL +5 -0
  35. priorwork-0.1.0.dist-info/entry_points.txt +2 -0
  36. priorwork-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. priorwork-0.1.0.dist-info/top_level.txt +1 -0
priorwork/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """priorwork — build literature reviews for the social sciences in conversation with an AI agent."""
2
+
3
+ __version__ = "0.1.0"
priorwork/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
priorwork/api.py ADDED
@@ -0,0 +1,496 @@
1
+ """
2
+ Literature API client.
3
+
4
+ 役割分担:
5
+ - Semantic Scholar: 検索・論文取得・引用関係(主)
6
+ - OpenAlex: S2 の結果を DOI で照合して書誌情報(誌名・ISSN・巻号)を補正、
7
+ S2 に無い DOI の取得、被引用数順の引用関係
8
+
9
+ S2 は定番論文ほど DOI の誤り(別論文や SSRN 版の DOI)や ISSN の欠落があるため、
10
+ Zotero に取り込む DOI と SSCI 照合に使う ISSN は OpenAlex 側の値を優先する。
11
+ どちらのソースから取得しても、同じ形の正規化済み dict(`_record` 参照)を返す。
12
+ """
13
+
14
+ import hashlib
15
+ import html
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional
23
+ from urllib.parse import quote, urlencode
24
+
25
+ import requests
26
+
27
+ from . import ssci
28
+ from .i18n import t
29
+ from .ssci import classify_paper
30
+ from .workspace import ROOT
31
+
32
+
33
+ def _load_dotenv():
34
+ """Load <workspace>/.env regardless of the current working directory."""
35
+ path = ROOT / ".env"
36
+ if not path.exists():
37
+ return
38
+ try:
39
+ from dotenv import load_dotenv
40
+
41
+ load_dotenv(path)
42
+ return
43
+ except ImportError:
44
+ pass
45
+ for line in path.read_text(encoding="utf-8").splitlines():
46
+ line = line.strip()
47
+ if not line or line.startswith("#") or "=" not in line:
48
+ continue
49
+ k, v = line.split("=", 1)
50
+ k, v = k.strip(), v.strip().strip("'\"")
51
+ if k and v and k not in os.environ:
52
+ os.environ[k] = v
53
+
54
+
55
+ _load_dotenv()
56
+
57
+ S2_BASE_URL = "https://api.semanticscholar.org/graph/v1"
58
+ OPENALEX_BASE_URL = "https://api.openalex.org"
59
+
60
+ S2_PAPER_FIELDS = (
61
+ "paperId,title,abstract,authors,year,venue,publicationVenue,journal,citationCount,referenceCount,"
62
+ "openAccessPdf,tldr,externalIds,url,fieldsOfStudy,publicationTypes"
63
+ )
64
+ # bulk 検索は tldr を返せない
65
+ S2_BULK_FIELDS = S2_PAPER_FIELDS.replace(",tldr", "")
66
+ S2_LINKED_FIELDS = "paperId,title,authors,year,venue,publicationVenue,journal,citationCount,externalIds,publicationTypes"
67
+ OPENALEX_SELECT = (
68
+ "id,doi,title,display_name,publication_year,type,authorships,primary_location,biblio,"
69
+ "cited_by_count,referenced_works_count,abstract_inverted_index,open_access,topics"
70
+ )
71
+
72
+ OPENALEX_BATCH = 50
73
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
74
+ BACKOFF_DELAYS = [5, 10, 20, 40, 60]
75
+
76
+ CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "priorwork"
77
+ CACHE_TTL_SEC = float(os.environ.get("PRIORWORK_CACHE_TTL_DAYS") or 7) * 86400
78
+
79
+
80
+ def _error_detail(resp: requests.Response) -> str:
81
+ """エラー本文を 1 行に要約する(HTML のエラーページをそのまま出さない)。"""
82
+ text = resp.text or ""
83
+ if "html" in resp.headers.get("Content-Type", "") or text.lstrip().startswith("<"):
84
+ title = re.search(r"<title[^>]*>(.*?)</title>", text, re.S | re.I)
85
+ return re.sub(r"\s+", " ", title.group(1)).strip() if title else t("an HTML error page")
86
+ return re.sub(r"\s+", " ", text)[:200].strip()
87
+
88
+
89
+ class ApiError(RuntimeError):
90
+ def __init__(self, service: str, message: str, status: Optional[int] = None):
91
+ super().__init__(f"[{service}] {message}")
92
+ self.service = service
93
+ self.status = status
94
+
95
+
96
+ # ---------------- Cache ----------------
97
+
98
+ def _cache_path(key: str) -> Path:
99
+ return CACHE_DIR / (hashlib.sha256(key.encode("utf-8")).hexdigest() + ".json")
100
+
101
+
102
+ def _cache_get(key: str) -> Optional[Any]:
103
+ path = _cache_path(key)
104
+ try:
105
+ if time.time() - path.stat().st_mtime > CACHE_TTL_SEC:
106
+ return None
107
+ return json.loads(path.read_text(encoding="utf-8"))
108
+ except (OSError, ValueError):
109
+ return None
110
+
111
+
112
+ def _cache_set(key: str, data: Any):
113
+ path = _cache_path(key)
114
+ try:
115
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
116
+ tmp = path.with_suffix(".tmp")
117
+ tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
118
+ os.replace(tmp, path)
119
+ except OSError:
120
+ pass
121
+
122
+
123
+ # ---------------- Identifier parsing ----------------
124
+
125
+ def parse_identifier(identifier: str) -> Dict[str, str]:
126
+ """
127
+ DOI / URL / S2 paperId / arXiv ID / OpenAlex ID を判別する。
128
+ Returns {"type": "doi|s2|arxiv|openalex|raw", "value": ..., "s2_id": ...}
129
+ """
130
+ raw = identifier.strip()
131
+
132
+ m = re.search(r"doi\.org/(10\.\d{4,9}/\S+)", raw, re.IGNORECASE)
133
+ if m or re.match(r"^(doi:)?10\.\d{4,9}/\S+$", raw, re.IGNORECASE):
134
+ doi = m.group(1) if m else re.sub(r"^doi:", "", raw, flags=re.IGNORECASE)
135
+ return {"type": "doi", "value": doi, "s2_id": f"DOI:{doi}"}
136
+
137
+ m = re.search(r"openalex\.org/(W\d+)", raw, re.IGNORECASE) or re.match(r"^(W\d+)$", raw, re.IGNORECASE)
138
+ if m:
139
+ wid = m.group(1).upper()
140
+ return {"type": "openalex", "value": wid, "s2_id": ""}
141
+
142
+ m = re.search(r"semanticscholar\.org/paper/(?:[^/]+/)?([a-f0-9]{40})", raw, re.IGNORECASE) or re.match(
143
+ r"^([a-f0-9]{40})$", raw, re.IGNORECASE
144
+ )
145
+ if m:
146
+ return {"type": "s2", "value": m.group(1), "s2_id": m.group(1)}
147
+
148
+ m = re.search(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})", raw, re.IGNORECASE) or re.match(
149
+ r"^(?:arxiv:)?(\d{4}\.\d{4,5})(?:v\d+)?$", raw, re.IGNORECASE
150
+ )
151
+ if m:
152
+ return {"type": "arxiv", "value": m.group(1), "s2_id": f"ARXIV:{m.group(1)}"}
153
+
154
+ return {"type": "raw", "value": raw, "s2_id": raw}
155
+
156
+
157
+ # ---------------- Normalization ----------------
158
+
159
+ def _bare_doi(doi: Optional[str]) -> str:
160
+ return re.sub(r"^https?://(dx\.)?doi\.org/", "", doi or "", flags=re.IGNORECASE)
161
+
162
+
163
+ def _record(**kw) -> Dict[str, Any]:
164
+ rec = {
165
+ "id": "",
166
+ "source": "",
167
+ "title": "",
168
+ "authors": [],
169
+ "year": None,
170
+ "journal_name": "",
171
+ "volume": "",
172
+ "issue": "",
173
+ "pages": "",
174
+ "issns": [],
175
+ "source_type": "",
176
+ "publication_types": [],
177
+ "doi": "",
178
+ "url": "",
179
+ "citation_count": 0,
180
+ "reference_count": 0,
181
+ "abstract": "",
182
+ "tldr": "",
183
+ "oa_pdf": "",
184
+ "fields": [],
185
+ "openalex_id": "",
186
+ "verified": False, # OpenAlex で DOI 照合済みか
187
+ "warnings": [],
188
+ }
189
+ rec.update({k: v for k, v in kw.items() if v is not None})
190
+ # 雑誌側の脚注記号("...Jobs*" など)が Markdown の強調を壊すので落とす
191
+ rec["title"] = re.sub(r"\s*[*†‡]+$", "", rec["title"])
192
+ rec["warnings"] = list(rec["warnings"])
193
+ if re.search(r",\s*(?:edited\s+)?by\s+[A-Z]", rec["title"]) or re.fullmatch(r"(\d+)\s*-+\s*\1", rec["pages"]):
194
+ rec["warnings"].append(t("May be a book review or comment (guessed from the title or the page range)"))
195
+ rec["ssci"] = classify_paper(rec)
196
+ return rec
197
+
198
+
199
+ def from_s2(p: Dict[str, Any]) -> Dict[str, Any]:
200
+ venue = p.get("publicationVenue") or {}
201
+ journal = p.get("journal") or {}
202
+ issns = [i for i in [venue.get("issn"), *(venue.get("alternate_issns") or [])] if i]
203
+ doi = (p.get("externalIds") or {}).get("DOI") or ""
204
+ return _record(
205
+ id=p.get("paperId") or "",
206
+ source="SemanticScholar",
207
+ title=(p.get("title") or "").strip(),
208
+ authors=[a.get("name", "") for a in (p.get("authors") or []) if a.get("name")],
209
+ year=p.get("year"),
210
+ journal_name=html.unescape(journal.get("name") or venue.get("name") or p.get("venue") or ""),
211
+ volume=(journal.get("volume") or "").strip(),
212
+ pages=(journal.get("pages") or "").strip(),
213
+ issns=issns,
214
+ source_type=venue.get("type") or "",
215
+ publication_types=p.get("publicationTypes") or [],
216
+ doi=doi,
217
+ url=f"https://doi.org/{doi}" if doi else (p.get("url") or ""),
218
+ citation_count=p.get("citationCount") or 0,
219
+ reference_count=p.get("referenceCount") or 0,
220
+ abstract=(p.get("abstract") or "").strip(),
221
+ tldr=((p.get("tldr") or {}).get("text") or "").strip(),
222
+ oa_pdf=(p.get("openAccessPdf") or {}).get("url") or "",
223
+ fields=p.get("fieldsOfStudy") or [],
224
+ )
225
+
226
+
227
+ def from_openalex(w: Dict[str, Any]) -> Dict[str, Any]:
228
+ loc = w.get("primary_location") or {}
229
+ src = loc.get("source") or {}
230
+ biblio = w.get("biblio") or {}
231
+ pages = biblio.get("first_page") or ""
232
+ if pages and biblio.get("last_page"):
233
+ pages += f"-{biblio['last_page']}"
234
+
235
+ abstract = ""
236
+ inv = w.get("abstract_inverted_index")
237
+ if isinstance(inv, dict):
238
+ positions = sorted((pos, word) for word, poss in inv.items() for pos in poss)
239
+ abstract = " ".join(word for _, word in positions)
240
+
241
+ doi = _bare_doi(w.get("doi"))
242
+ work_id = (w.get("id") or "").replace("https://openalex.org/", "")
243
+ return _record(
244
+ id=work_id,
245
+ openalex_id=work_id,
246
+ source="OpenAlex",
247
+ title=(w.get("title") or w.get("display_name") or "").strip(),
248
+ authors=[(a.get("author") or {}).get("display_name", "") for a in (w.get("authorships") or [])],
249
+ year=w.get("publication_year"),
250
+ journal_name=src.get("display_name") or "",
251
+ volume=biblio.get("volume") or "",
252
+ issue=biblio.get("issue") or "",
253
+ pages=pages,
254
+ issns=src.get("issn") or ([src["issn_l"]] if src.get("issn_l") else []),
255
+ source_type=src.get("type") or "",
256
+ publication_types=[w["type"]] if w.get("type") else [],
257
+ doi=doi,
258
+ url=f"https://doi.org/{doi}" if doi else (w.get("id") or ""),
259
+ citation_count=w.get("cited_by_count") or 0,
260
+ reference_count=w.get("referenced_works_count") or 0,
261
+ abstract=abstract,
262
+ oa_pdf=loc.get("pdf_url") or (w.get("open_access") or {}).get("oa_url") or "",
263
+ fields=[t.get("display_name") for t in (w.get("topics") or [])[:3]],
264
+ )
265
+
266
+
267
+ # ---------------- Client ----------------
268
+
269
+ class LiteratureClient:
270
+ def __init__(self, use_cache: bool = True):
271
+ self.s2_api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY") or os.environ.get("S2_API_KEY")
272
+ self.openalex_api_key = os.environ.get("OPENALEX_API_KEY")
273
+ self.openalex_mailto = os.environ.get("OPENALEX_MAILTO")
274
+ self.use_cache = use_cache and os.environ.get("PRIORWORK_NO_CACHE") != "1"
275
+ self.max_retries = int(os.environ.get("PRIORWORK_MAX_RETRIES") or 5)
276
+ self._last_request: Dict[str, float] = {}
277
+
278
+ # ---- HTTP ----
279
+
280
+ def _throttle(self, service: str):
281
+ interval = {"SemanticScholar": 1.1 if self.s2_api_key else 1.5}.get(service, 0.2)
282
+ elapsed = time.time() - self._last_request.get(service, 0.0)
283
+ if elapsed < interval:
284
+ time.sleep(interval - elapsed)
285
+ self._last_request[service] = time.time()
286
+
287
+ def _get_json(self, service: str, url: str, params: Dict[str, Any], headers: Dict[str, str],
288
+ secret_params: Optional[Dict[str, Any]] = None) -> Any:
289
+ params = {k: v for k, v in params.items() if v is not None}
290
+ cache_key = f"{url}?{urlencode(sorted(params.items()))}"
291
+ if self.use_cache:
292
+ cached = _cache_get(cache_key)
293
+ if cached is not None:
294
+ return cached
295
+
296
+ all_params = {**params, **{k: v for k, v in (secret_params or {}).items() if v}}
297
+ for attempt in range(self.max_retries + 1):
298
+ self._throttle(service)
299
+ try:
300
+ resp = requests.get(url, params=all_params, headers=headers, timeout=30)
301
+ except (requests.ConnectionError, requests.Timeout) as e:
302
+ status, detail, retry_after = None, t("network error: {error}", error=e.__class__.__name__), None
303
+ else:
304
+ if resp.ok:
305
+ data = resp.json()
306
+ if self.use_cache:
307
+ _cache_set(cache_key, data)
308
+ return data
309
+ status, detail = resp.status_code, _error_detail(resp)
310
+ retry_after = resp.headers.get("Retry-After")
311
+ if status not in RETRY_STATUSES:
312
+ raise ApiError(service, f"HTTP {status}: {detail}", status)
313
+
314
+ if attempt >= self.max_retries:
315
+ raise ApiError(service, t("gave up after {n|# retry|# retries} ({detail})", n=self.max_retries, detail=status or detail), status)
316
+ try:
317
+ wait = max(float(retry_after), 1.0)
318
+ except (TypeError, ValueError):
319
+ wait = BACKOFF_DELAYS[min(attempt, len(BACKOFF_DELAYS) - 1)]
320
+ reason = t("rate limited (HTTP 429)") if status == 429 else (f"HTTP {status}" if status else detail)
321
+ print(f"[{service}] " + t("{reason}. Retrying in {seconds} s ({attempt}/{max})", reason=reason,
322
+ seconds=f"{wait:.0f}", attempt=attempt + 1, max=self.max_retries),
323
+ file=sys.stderr)
324
+ time.sleep(wait)
325
+ raise AssertionError("unreachable")
326
+
327
+ def _s2(self, path: str, params: Dict[str, Any]) -> Any:
328
+ headers = {"User-Agent": "priorwork/0.1", "Accept": "application/json"}
329
+ if self.s2_api_key:
330
+ headers["x-api-key"] = self.s2_api_key
331
+ return self._get_json("SemanticScholar", f"{S2_BASE_URL}{path}", params, headers)
332
+
333
+ def _openalex(self, path: str, params: Dict[str, Any]) -> Any:
334
+ ua = "priorwork/0.1" + (f" (mailto:{self.openalex_mailto})" if self.openalex_mailto else "")
335
+ return self._get_json(
336
+ "OpenAlex", f"{OPENALEX_BASE_URL}{path}", params, {"User-Agent": ua},
337
+ secret_params={"api_key": self.openalex_api_key, "mailto": self.openalex_mailto},
338
+ )
339
+
340
+ # ---- Semantic Scholar ----
341
+
342
+ def _search_s2(self, query: str, limit: int, year: Optional[str], bulk: bool) -> List[Dict[str, Any]]:
343
+ if bulk:
344
+ # bulk 検索: AND(+) / OR(|) / 除外(-) / "フレーズ" が使え、被引用数順に最大 1000 件返る
345
+ data = self._s2("/paper/search/bulk", {"query": query, "fields": S2_BULK_FIELDS, "year": year,
346
+ "sort": "citationCount:desc"})
347
+ else:
348
+ data = self._s2("/paper/search", {"query": query, "limit": min(limit, 100), "fields": S2_PAPER_FIELDS,
349
+ "year": year})
350
+ return [from_s2(p) for p in (data.get("data") or [])[:limit]]
351
+
352
+ def _get_paper_s2(self, s2_id: str) -> Dict[str, Any]:
353
+ return from_s2(self._s2(f"/paper/{quote(s2_id, safe=':')}", {"fields": S2_PAPER_FIELDS}))
354
+
355
+ def _linked_s2(self, s2_id: str, kind: str, limit: int) -> List[Dict[str, Any]]:
356
+ key = "citingPaper" if kind == "citations" else "citedPaper"
357
+ data = self._s2(f"/paper/{quote(s2_id, safe=':')}/{kind}",
358
+ {"limit": min(limit, 1000), "fields": S2_LINKED_FIELDS})
359
+ if data.get("data") is None:
360
+ # 出版社の意向で引用データが非公開(elided)の論文は data: null になる
361
+ raise ApiError("SemanticScholar", t("the publisher does not make the {kind} public", kind=kind), 404)
362
+ return [from_s2(item[key]) for item in data.get("data") or [] if (item.get(key) or {}).get("paperId")]
363
+
364
+ # ---- OpenAlex ----
365
+
366
+ def _get_paper_openalex(self, ident: Dict[str, str]) -> Dict[str, Any]:
367
+ if ident["type"] not in ("doi", "openalex"):
368
+ raise ApiError("OpenAlex", t("OpenAlex can only look up a DOI or an OpenAlex ID: {id}", id=ident["value"]), 404)
369
+ path = f"/works/doi:{ident['value']}" if ident["type"] == "doi" else f"/works/{ident['value']}"
370
+ rec = from_openalex(self._openalex(path, {"select": OPENALEX_SELECT}))
371
+ rec["verified"] = True
372
+ return rec
373
+
374
+ def _verify_with_openalex(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
375
+ """S2 のレコードを DOI で OpenAlex と照合し、書誌情報を補正する(まとめて照会するので API 呼び出しは少ない)。"""
376
+ targets = [r for r in records if r["source"] == "SemanticScholar"]
377
+ for r in targets:
378
+ if not r["doi"]:
379
+ r["warnings"].append(t("No DOI"))
380
+
381
+ with_doi = [r for r in targets if r["doi"]]
382
+ works: Dict[str, Dict[str, Any]] = {}
383
+ try:
384
+ for i in range(0, len(with_doi), OPENALEX_BATCH):
385
+ chunk = with_doi[i:i + OPENALEX_BATCH]
386
+ data = self._openalex("/works", {
387
+ "filter": "doi:" + "|".join(r["doi"].lower() for r in chunk),
388
+ "per_page": OPENALEX_BATCH,
389
+ "select": OPENALEX_SELECT,
390
+ })
391
+ for w in data.get("results") or []:
392
+ works[_bare_doi(w.get("doi")).lower()] = w
393
+ except ApiError as e:
394
+ print("[Notice] " + t("{error} → the DOIs are not checked with OpenAlex (the details from S2 are kept)",
395
+ error=e), file=sys.stderr)
396
+ for r in with_doi:
397
+ r["warnings"].append(t("DOI not verified"))
398
+ return records
399
+
400
+ for r in with_doi:
401
+ w = works.get(r["doi"].lower())
402
+ if not w:
403
+ r["warnings"].append(t("OpenAlex does not know the DOI (verify the DOI)"))
404
+ continue
405
+ oa = from_openalex(w)
406
+ if ssci.normalize_title(oa["title"]) != ssci.normalize_title(r["title"]):
407
+ r["warnings"].append(t("The DOI points to another title: \"{title}\" (verify the DOI)", title=oa["title"]))
408
+ s2_journal, s2_status = r["journal_name"], r["ssci"]["status"]
409
+ # Zotero 取り込み・SSCI 照合に使う書誌情報は、DOI の実体(OpenAlex)側で丸ごと置き換える
410
+ for field in ("journal_name", "issns", "source_type", "publication_types", "volume", "issue", "pages"):
411
+ r[field] = oa[field]
412
+ if not r["abstract"]:
413
+ r["abstract"] = oa["abstract"]
414
+ r["verified"] = True
415
+ r["openalex_id"] = oa["openalex_id"]
416
+ r["ssci"] = ssci.classify_paper(r)
417
+ if r["ssci"]["status"] == ssci.PREPRINT and s2_status != ssci.PREPRINT and s2_journal:
418
+ r["warnings"].append(t("S2 gives the journal as \"{journal}\", but the DOI is a working paper / preprint version "
419
+ "(find the DOI of the published version)", journal=s2_journal))
420
+ return records
421
+
422
+ def _linked_openalex(self, ident: Dict[str, str], kind: str, limit: int) -> List[Dict[str, Any]]:
423
+ if ident["type"] in ("s2", "arxiv"):
424
+ # OpenAlex は S2 ID / arXiv ID を直接引けないので、S2 で DOI を得てから引き直す
425
+ doi = self._get_paper_s2(ident["s2_id"])["doi"]
426
+ if not doi:
427
+ raise ApiError("OpenAlex", t("There is no DOI, so OpenAlex cannot look it up: {id}", id=ident["value"]))
428
+ ident = {"type": "doi", "value": doi}
429
+ work_id = ident["value"] if ident["type"] == "openalex" else self._get_paper_openalex(ident)["id"]
430
+ data = self._openalex("/works", {
431
+ "filter": f"{'cites' if kind == 'citations' else 'cited_by'}:{work_id}",
432
+ "per_page": min(limit, 200),
433
+ "select": OPENALEX_SELECT,
434
+ "sort": "cited_by_count:desc",
435
+ })
436
+ return [dict(from_openalex(w), verified=True) for w in data.get("results") or []]
437
+
438
+ # ---- Public interface ----
439
+
440
+ def search(self, query: str, limit: int = 20, year: Optional[str] = None, bulk: bool = False) -> List[Dict[str, Any]]:
441
+ """S2 で検索し、結果を OpenAlex で DOI 照合する。S2 が失敗したらエラー(OpenAlex 検索は精度が低いため使わない)。"""
442
+ return self._verify_with_openalex(self._search_s2(query, limit, year, bulk))
443
+
444
+ def get_paper(self, identifier: str) -> Dict[str, Any]:
445
+ """S2 で取得して DOI 照合する。S2 に無い(404)DOI / OpenAlex ID は OpenAlex から取得する。"""
446
+ ident = parse_identifier(identifier)
447
+ if ident["s2_id"]:
448
+ try:
449
+ return self._verify_with_openalex([self._get_paper_s2(ident["s2_id"])])[0]
450
+ except ApiError as e:
451
+ if e.status != 404:
452
+ raise
453
+ print("[Notice] " + t("Not in Semantic Scholar; fetching it from OpenAlex: {id}", id=ident["value"]),
454
+ file=sys.stderr)
455
+ try:
456
+ return self._get_paper_openalex(ident)
457
+ except ApiError as e:
458
+ if e.status != 404:
459
+ raise
460
+ raise ApiError("OpenAlex", t("{id} is in neither Semantic Scholar nor OpenAlex (perhaps a domestic "
461
+ "journal, a very recent paper or a wrong DOI)", id=ident["value"]), 404)
462
+
463
+ def get_linked(self, identifier: str, kind: str, limit: int = 10, sort: str = "recent") -> List[Dict[str, Any]]:
464
+ """
465
+ kind: "citations"(この論文を引用している論文)| "references"(この論文の参考文献)
466
+ sort: "recent" は S2(S2 に無い論文なら OpenAlex)、"cited" は被引用数順にソートできる OpenAlex
467
+ """
468
+ ident = parse_identifier(identifier)
469
+ if sort == "recent" and ident["s2_id"]:
470
+ try:
471
+ return self._verify_with_openalex(self._linked_s2(ident["s2_id"], kind, limit))
472
+ except ApiError as e:
473
+ if e.status != 404:
474
+ raise
475
+ print("[Notice] " + t("{error}: {id} → fetching it from OpenAlex (by citations)", error=e, id=ident["value"]),
476
+ file=sys.stderr)
477
+ return self._linked_openalex(ident, kind, limit)
478
+
479
+ # ---- Snowballing (OpenAlex) ----
480
+
481
+ def openalex_works(self, dois: List[str] = (), openalex_ids: List[str] = (),
482
+ select: str = OPENALEX_SELECT) -> List[Dict[str, Any]]:
483
+ """DOI / OpenAlex ID で OpenAlex の work を(生の JSON のまま)まとめて取得する。"""
484
+ works = []
485
+ for name, values in (("doi", [d.lower() for d in dois]), ("openalex", list(openalex_ids))):
486
+ for i in range(0, len(values), OPENALEX_BATCH):
487
+ data = self._openalex("/works", {"filter": f"{name}:" + "|".join(values[i:i + OPENALEX_BATCH]),
488
+ "per_page": OPENALEX_BATCH, "select": select})
489
+ works.extend(data.get("results") or [])
490
+ return works
491
+
492
+ def citing_work_ids(self, work_id: str, limit: int) -> List[str]:
493
+ """work_id を引用している論文の OpenAlex ID(被引用数の多い順)。"""
494
+ data = self._openalex("/works", {"filter": f"cites:{work_id}", "sort": "cited_by_count:desc",
495
+ "per_page": min(limit, 200), "select": "id"})
496
+ return [w["id"].replace("https://openalex.org/", "") for w in data.get("results") or []]
@@ -0,0 +1,90 @@
1
+ # Instructions for agents
2
+
3
+ > This file is generated by `priorwork sync`. Do not edit it. Instructions specific to this workspace are in `AGENTS.local.md`; read that too.
4
+
5
+ This repository is a workspace for writing topic-based literature reviews in the social sciences, in conversation with the user.
6
+ You are the research assistant: you find and record literature with the `./priorwork` command and write the text of the reports.
7
+
8
+ ## Directories
9
+
10
+ - `reports/YYYYMMDD_<slug>.md` … the working report (with managed blocks and card fields). The agent writes it.
11
+ - `reports/YYYYMMDD_<slug>.html` … **the version for reading, made by `./priorwork export`. This is what people read.** Show it to the user when the review is done.
12
+ If fields are empty, papers are unchecked or `check` still reports ERRORs, it is marked "Draft" at the top.
13
+ - `.priorwork/surveys/YYYYMMDD_<slug>.json` … candidates, decisions and the search log (the state). Only `./priorwork` changes it.
14
+ - `.priorwork/config.json` … the workspace settings (its language). Only `./priorwork` changes it.
15
+ - `.priorwork/cache/`, `.priorwork/data/`, `.priorwork/sync.json` … extracted full texts, the SSCI list, the sync record. Do not touch them, and do not send the user there.
16
+
17
+ When you report to the user, say what changed in the report, not in the internal files.
18
+
19
+ ## Principles
20
+
21
+ - **`./priorwork` owns the state.** Never edit the JSON in `.priorwork/surveys/` directly.
22
+ - **Parts of the Markdown are generated.** Everything between `<!-- BEGIN priorwork:… -->` and `<!-- END priorwork:… -->` is regenerated by `./priorwork`. Write only the fields of each paper card and the text outside the blocks (background, theories, debates, …).
23
+ - **If `./priorwork status` warns that the skills are out of date, tell the user and run `./priorwork sync`.**
24
+ - **The user decides.** For the scope and for each paper, give a recommendation and wait for the user's decision. Never include or exclude on your own.
25
+ - **The workspace is a Git repository pushed to GitHub (a private repository).** At the end of each step, offer to commit the changes to `reports/*.md`, `.priorwork/surveys/` and so on. Push only when the user agrees. Never commit `.env` (API keys).
26
+ - **When a conversation resumes, look at the state first.** `./priorwork status` (the list) → `./priorwork status <survey>` (progress and next steps).
27
+ - **The user also works from the Priorwork sidebar in VS Code** (searching, recording decisions, exporting). When the user says "I screened them" or "I included those", reload with `./priorwork list <survey>` instead of relying on what you remember.
28
+
29
+ ## Steps and skills
30
+
31
+ | Step | Skill | Main commands |
32
+ | :--- | :--- | :--- |
33
+ | Agree on the scope, create the survey, first searches | `/survey-new` | `new`, `search --into` |
34
+ | Screen the candidates | `/survey-screen` | `list`, `include`, `exclude`, `maybe` |
35
+ | Chase citations to find what was missed | `/survey-snowball` | `snowball` |
36
+ | Fill in the paper cards | `/survey-extract` | `fulltext`, `render` |
37
+ | Check and fix | `/survey-check` | `check` |
38
+
39
+ At the end of each step, report the result to the user briefly and ask what to do next.
40
+
41
+ ## Depth (quick / full)
42
+
43
+ A survey has a depth, agreed with the user in `/survey-new` (full by default and when unset). `./priorwork status <survey>` shows it.
44
+
45
+ - **quick**: an overview of a narrow topic. One or two queries, about 20 included papers, citation chasing optional, cards from abstracts are fine.
46
+ - **full**: a broad topic. Searches per subtopic, citation chasing, core papers checked in the full text. Also write the background, theories and debates.
47
+ - The numbers are guides, not limits. The rules and `./priorwork check` are the same at both depths, except that for full, `./priorwork check` gives a WARN when citations have not been chased and an INFO for cards that are "abstract only".
48
+ - The depth can be changed later with `./priorwork scope SURVEY --depth full`. The agent only recommends; it does not decide.
49
+
50
+ ## Rules
51
+
52
+ 1. **The target is international peer-reviewed journal articles, preferring SSCI journals**
53
+ - Do not recommend including working papers (NBER, IZA DP, …), preprints (SSRN, arXiv, …), in-house bulletins or conference papers unless the user asks for them.
54
+ - Classic books may be mentioned in the background, but are not included papers.
55
+ 2. **Never make anything up**
56
+ - Every paper you mention in the text must be registered in `./priorwork`. Never write authors, years or DOIs from memory.
57
+ - Write on a card only what the abstract or full text says, and always update its evidence level (unchecked / abstract only / full text checked).
58
+ 3. **Never ignore a ⚠️**
59
+ - Papers with warnings such as "the DOI points to another title", "the DOI is a working paper / preprint version" or "may be a book review or comment" must be reported to the user and replaced with the published version as in `/survey-screen`.
60
+ 4. **Never overstate the SSCI status**
61
+ - Do not call a 🟡 (guessed from the journal name) "in SSCI". Check a single journal with `./priorwork journal "<name or ISSN>"`.
62
+ 5. **Do not make BibTeX citation keys** (the user manages them in Zotero). `./priorwork` generates the references with DOIs.
63
+ - The user adds papers to Zotero by hand. The agent only reports the included papers missing from Zotero with `./priorwork zotero <survey>` and never writes to Zotero.
64
+ 6. **Run `./priorwork check` before saying anything is done.** If ERRORs remain, do not call it done; say what is left.
65
+ 7. **Search in English.** Rephrase a topic given in another language as English queries with synonyms before searching.
66
+ 8. **Waiting for the APIs is normal.** Do not interrupt retries after a rate limit. If a command ends with an error, wait a little and run it again.
67
+ 9. **Some literature cannot be registered.** Domestic journals (J-STAGE and the like) are often missing from Semantic Scholar and OpenAlex, so `add` fails. Do not force it; tell the user.
68
+
69
+ ## Command reference
70
+
71
+ ```bash
72
+ ./priorwork export SURVEY [--format html|docx|md] [--with-abstracts] # write the version for reading
73
+ ./priorwork doctor [--online] # diagnose the setup (when something is wrong, or for a new workspace)
74
+ ./priorwork sync # update AGENTS.md and the skills to the engine's version (after updating the engine)
75
+ ./priorwork status [SURVEY]
76
+ ./priorwork new "<topic>" --slug <slug> [--depth quick|full] [--question ... --years ... --fields ... --inclusion ... --exclusion ...]
77
+ ./priorwork scope SURVEY [--question ...] [--depth quick|full]
78
+ ./priorwork search "<English query>" [--into SURVEY] [--bulk] [--sort citations|relevance|recent|cpy] [--year 2010-2024] [--ssci-only] [--limit N]
79
+ ./priorwork list SURVEY [--status candidate maybe included excluded] [--abstract]
80
+ ./priorwork include SURVEY N... ./priorwork exclude SURVEY N... --reason "..." ./priorwork maybe SURVEY N... ./priorwork reset SURVEY N...
81
+ ./priorwork add SURVEY <DOI>... [--candidate]
82
+ ./priorwork snowball SURVEY [--direction both|references|citations] [--limit N] [--min-links N]
83
+ ./priorwork fulltext SURVEY N [--pdf PATH]
84
+ ./priorwork zotero [SURVEY]
85
+ ./priorwork render SURVEY
86
+ ./priorwork check SURVEY [--offline]
87
+ ./priorwork get <DOI> ./priorwork citations <DOI> [--sort cited] ./priorwork references <DOI> [--sort cited] ./priorwork journal "<journal>"
88
+ ```
89
+
90
+ `SURVEY` is a file name without the extension, such as `20260917_minimum_wage_employment`, or the slug (`minimum_wage_employment`).
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: survey-check
3
+ description: >-
4
+ Validates a literature survey with `./priorwork check` (unregistered author-year citations, DOIs that
5
+ point to other papers, unfilled cards, stale matrix) and fixes the reported problems.
6
+ Use after editing a survey and before telling the user that a section or the survey is done.
7
+ ---
8
+
9
+ # Check the survey and fix it
10
+
11
+ Follow the rules in [AGENTS.md](../../../AGENTS.md).
12
+
13
+ ## Steps
14
+
15
+ 1. Check it.
16
+
17
+ ```bash
18
+ ./priorwork check <survey>
19
+ ```
20
+
21
+ 2. Deal with each finding.
22
+
23
+ | Finding | What to do |
24
+ | :--- | :--- |
25
+ | A citation "X (year)" in the text has no registered paper | Confirm it exists and get its details with `./priorwork search` / `./priorwork get`, then register it with `./priorwork add <survey> <DOI>`. If it cannot be found, delete the statement or mark it "citation needed" and tell the user |
26
+ | A DOI in the text is not registered | As above |
27
+ | A citation matches several included papers / no paper is 2001a | Change the citation in the text to match 2001a / 2001b as written in the references (section 8) |
28
+ | Citations not checked as organisations or table numbers (INFO) | If one is a paper, register it as above. Reports and statistics can stay |
29
+ | Something that is not a citation is taken for one | Put `<!-- priorwork:ignore-citation Author (year) -->` next to it. Never use this to hide a citation of a real paper |
30
+ | ⚠️ The DOI points to another title / a working paper version | Replace it as in "Including a paper whose DOI needs checking" in `/survey-screen` |
31
+ | The evidence is unchecked / fields are empty | Fill them in as in `/survey-extract` |
32
+ | The Markdown does not match the state file | `./priorwork render <survey>` |
33
+ | Managed blocks are missing | Restore the lost `<!-- BEGIN priorwork:… -->` / `<!-- END priorwork:… -->` from the git history or elsewhere |
34
+
35
+ 3. Repeat 1 and 2 until there are no ERRORs. Tell the user about the WARNs and INFOs that remain.
36
+
37
+ **Never tell the user it is done while ERRORs remain.**
38
+
39
+ ## 4. When there are no ERRORs
40
+
41
+ Write the version for reading and ask the user to look at it.
42
+
43
+ ```bash
44
+ ./priorwork export <survey>
45
+ ```
46
+
47
+ If it is marked "Draft" at the top, tell the user why (unchecked cards, empty sections, …) and do not call it done.