-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathagent_search.py
More file actions
2303 lines (2075 loc) · 103 KB
/
Copy pathagent_search.py
File metadata and controls
2303 lines (2075 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Agent search: a headless, single-command full-pipeline entry point for agents.
Why this module exists (R1 + R2 — read those research docs for the full story)
-----------------------------------------------------------------------------
Forensics on 154 real Claude-Code-session samples (R1) showed that when an agent
calls paper-search-pro from inside a larger orchestration, it loads the Skill but
then *only* drives ``openalex_helper`` for raw retrieval and skips classification,
saturation, HTML — every discipline step that lives in the human 14-STEP recipe.
This is not laziness: the agent's consumer is *itself* (it feeds the data to its
own judgement), so a human-facing HTML report solves a problem it does not have.
The external survey (R2) reached the matching conclusion: you cannot make an
agent follow a multi-step recipe by writing a stricter prompt — Opus 4.8 skips
user-defined sequences even with CLAUDE.md + rules + hooks (issue #65951). The
only reliable fix is to **bake the discipline into deterministic code** the agent
*cannot* bypass, and hand it back a single structured envelope.
So this module is the "agent path": one command runs the deterministic core
(multi-strategy retrieve -> federate dedup -> field tidy -> built-in heuristic
relevance score -> lightweight saturation signal -> quota snapshot) and prints a
single JSON envelope to stdout. It NEVER renders HTML, NEVER writes PRISMA, and
NEVER dispatches an LLM classification subagent. The human 14-STEP path is
untouched (R-19): this is a brand-new additive entry point.
Envelope contract (R2 §5.2, ai-native-cli-spec)
-----------------------------------------------
Success::
{
"ok": true,
"schema_version": "1.0",
"data": [ <paper>, ... ], # field-tidied, deduped, scored, sorted
"meta": {
"query": {...}, # topic / years / strategy params
"counts": {...}, # retrieved_raw / after_dedup / returned
"relevance": {...}, # heuristic descriptor (NOT an LLM RCS)
"saturation": {...}, # heuristic stop signal
"ratelimit": {...}, # quota_guard probe snapshot
"source_used": "openalex", # openalex | semantic_scholar
"warnings": [ ... ]
}
}
Failure::
{"ok": false, "schema_version": "1.0",
"error": {"code": "E_*", "message": "...", "retryable": bool},
"meta": {...}}
Each ``<paper>`` carries:
- the full UnifiedPaperEntity field set (so the agent gets rich data, R1 §4),
- ``relevance`` : {score: 0..1, label, method: "heuristic_v1", components: {...}}
— the built-in, always-computed, deterministic signal (B-2). It is a SIGNAL,
not a gate: every returned paper carries it; the agent may self-judge on top,
but it never gets data without a score.
- ``journal_rank`` : the SINGLE journal layer (v2.2 collapse) — CAS 区 / JCR Q·IF
/ SJR Q + an ``openalex`` open-impact sub-slot ({mean_citedness_2yr, h_index}),
or ``None`` when nothing joined. The legacy per-paper ``journal_metric`` key is
retired (its data lives in journal_rank now). Enrichment audit is in
``meta.enrichment``; the partition-filter decision is in ``meta.rank``.
- when ``--verify`` is on, a ``verify`` block (existence + abstract +
cross-source consistency markers).
Exit codes (aligned to error.code):
0 ok
2 E_NO_RESULTS (retryable=false) — search ran but returned nothing
4 E_CONFIG (retryable=false) — misconfiguration (e.g. SS w/o key)
7 E_RATE_LIMITED (retryable=true) — upstream throttled / quota gone
6 E_INTERNAL (retryable=false) — unexpected failure
"""
from __future__ import annotations
import math
import re
import time
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from .types import Config, UnifiedPaperEntity
# These imports are the deterministic core the agent path REUSES (no new search
# logic invented here — same backends the human path uses, R-14/enhance-not-rewrite).
from . import openalex_helper, ss_helper, quota_guard, crossref_helper
# Chinese supplemental sources (v2.3.0, B1): independent primary sources that emit
# the SAME UnifiedPaperEntity shape, so their results fold into federated dedup
# exactly like an openalex/ss strategy list. Explicit-flag only in the agent path
# (--with-nssd / --with-yiigle); no discipline-signal auto-routing here (§8).
from . import nssd_helper, yiigle_helper
from .federated_kg_resolver import federated_dedup, kg_to_list
# Feature A (v2.2 Wave A-2): multi-platform journal-rank intent + annotate/filter.
# journal_rank is the data layer (A-1); rank_intent parses NL queries; rank_filter
# annotates + filters the candidate pool. All additive / opt-in (R-19).
from . import journal_rank, rank_filter
from .rank_intent import parse_rank_intent
SCHEMA_VERSION = "1.0"
# Current UTC-ish "now" year for recency scoring. Kept as a module constant so a
# test can monkeypatch it deterministically rather than depending on wall-clock.
_CURRENT_YEAR = 2026
# ===========================================================================
# Error / envelope plumbing
# ===========================================================================
@dataclass
class AgentError(Exception):
"""A structured, envelope-ready error. code maps to an exit code."""
code: str
message: str
retryable: bool = False
def __str__(self) -> str: # pragma: no cover - trivial
return f"{self.code}: {self.message}"
# error.code -> process exit code
_EXIT_FOR_CODE = {
"E_NO_RESULTS": 2,
"E_CONFIG": 4,
"E_INTERNAL": 6,
"E_RATE_LIMITED": 7,
}
def _ok_envelope(data: List[Dict], meta: Dict) -> Dict:
return {"ok": True, "schema_version": SCHEMA_VERSION, "data": data, "meta": meta}
def _error_envelope(err: AgentError, meta: Optional[Dict] = None) -> Dict:
return {
"ok": False,
"schema_version": SCHEMA_VERSION,
"error": {"code": err.code, "message": err.message, "retryable": err.retryable},
"meta": meta or {},
}
# ===========================================================================
# Query tokenisation (for the heuristic relevance score)
# ===========================================================================
# Stopwords that carry no topical signal — excluded so "the role of memory in
# learning" scores on {role, memory, learning}, not on {the, of, in}.
_STOPWORDS = frozenset(
"""a an and are as at be but by for from has have in into is it its of on or
that the their to was were will with within without between among across over
under about via per vs versus using used use based study studies research
review analysis effect effects role""".split()
)
# Boolean-query operators SS/OpenAlex understand; we strip them for term scoring
# so "(machine learning) + (healthcare)" tokenises to {machine, learning,
# healthcare} rather than scoring the "+"/parens.
_TOKEN_RE = re.compile(r"[a-z0-9]+")
# CJK character class (shared source of truth): ideograph + CJK-extension +
# compatibility ranges. Used both for the mechanical zh query signal
# (``_query_has_cjk`` below reuses it) and for CJK n-gram coverage (#9). A CJK
# query has no whitespace token boundaries, so the latin ``_TOKEN_RE`` yields
# NOTHING and the heuristic score would floor at citation+recency only. We add
# character 2-grams for the CJK runs so a Chinese query is query-grounded too.
# A pure-ASCII query contains no CJK char, so ``_cjk_ngrams`` returns [] and the
# English tokenise / coverage path is byte-for-byte unchanged (R-19).
_CJK_CLASS = "㐀-䶿一-鿿豈-\U00020000-\U0002ffff\U0002f800-\U0002fa1f"
_CJK_RUN_RE = re.compile(f"[{_CJK_CLASS}]+")
def _cjk_ngrams(text: Optional[str]) -> List[str]:
"""Character 2-grams over each maximal CJK run in ``text`` (a length-1 run
yields that single char). Returns [] for any text without a CJK character, so
the English tokenise / coverage path is completely unaffected (#9 / R-19)."""
grams: List[str] = []
for run in _CJK_RUN_RE.findall(text or ""):
if len(run) == 1:
grams.append(run)
else:
for i in range(len(run) - 1):
grams.append(run[i : i + 2])
return grams
def _tokenize_query(query: str) -> List[str]:
"""Lowercase the query, drop boolean punctuation and stopwords, keep terms
of length >= 3 (so 'ml' survives only as 'machine'/'learning' when spelled
out; very short tokens are noise for coverage scoring).
A CJK query has no whitespace boundaries, so the latin token pass yields
nothing; we additionally emit CJK character 2-grams so a Chinese query is
query-grounded (#9). A pure-ASCII query produces no CJK grams, so its term
list is byte-for-byte unchanged (R-19)."""
low = (query or "").lower()
raw = _TOKEN_RE.findall(low)
terms = [t for t in raw if len(t) >= 3 and t not in _STOPWORDS]
# Additive CJK support: [] for a pure-ASCII query, so the English path is intact.
terms.extend(_cjk_ngrams(low))
# Preserve order but dedupe so a repeated term doesn't inflate coverage.
seen = set()
out: List[str] = []
for t in terms:
if t not in seen:
seen.add(t)
out.append(t)
return out
def _coverage(terms: List[str], text: Optional[str]) -> float:
"""Fraction of query terms present in text (substring match on whole-word
boundaries-ish: we just test token membership in the text's token set).
Latin terms match on token-set membership (substring-free, so 'cat' does not
match 'category'). CJK n-gram terms match as substrings — CJK text has no word
boundaries, so a Chinese query grounds via substring presence (#9). For a
pure-ASCII query every term is latin, so this is byte-for-byte the original
token-membership behaviour (R-19)."""
if not terms or not text:
return 0.0
low = text.lower()
text_tokens = set(_TOKEN_RE.findall(low))
hits = 0
for t in terms:
if _CJK_RUN_RE.search(t): # CJK n-gram term -> substring match
if t in low:
hits += 1
elif t in text_tokens: # latin term -> token-set membership (unchanged)
hits += 1
return hits / len(terms)
# ===========================================================================
# Heuristic relevance score (B-2) — deterministic, always computed, NOT an RCS
# ===========================================================================
#
# Design rationale (documented in impl_3c_notes.md):
# The score is a weighted blend of four query-grounded signals. It is a SIGNAL
# the agent can build on, never a silent gate. It is explicitly labelled
# "heuristic_v1" so no consumer mistakes it for the LLM-based RCS (0-10) that the
# human path computes via a classification subagent.
#
# relevance = 0.50 * title_coverage
# + 0.25 * abstract_coverage
# + 0.15 * citation_signal (log-scaled, saturating)
# + 0.10 * recency_signal (linear over a 25-year window)
#
# Why these weights:
# - Title coverage is the strongest precision signal a query word appearing in a
# title is far more diagnostic of on-topic-ness than in a long abstract, so it
# carries the most weight (0.50).
# - Abstract coverage broadens recall but is noisier (abstracts mention many
# adjacent concepts), hence 0.25.
# - Citation signal is a *quality/impact* prior, not a topical one; it nudges
# well-cited on-topic papers up without letting a hugely-cited off-topic paper
# win (capped at 0.15 and log-scaled so 100 vs 10000 cites differ modestly).
# - Recency is a mild freshness prior (0.10) so that, among similarly on-topic
# and similarly cited papers, newer ones edge ahead — useful for the agent's
# "is this a live area" judgement without burying seminal old work.
#
# Components are returned alongside the score so the agent (and reviewers) can
# see exactly why a paper scored what it did — no black box.
_W_TITLE = 0.50
_W_ABSTRACT = 0.25
_W_CITATION = 0.15
_W_RECENCY = 0.10
# Citation saturates: log10(cites+1) / log10(CAP+1), clamped to 1. CAP=2000 means
# a 2000-cite paper gets ~full citation credit; beyond that the marginal signal
# is flat (a 50k-cite classic shouldn't dominate purely on impact).
_CITATION_CAP = 2000.0
# Recency window: papers older than this many years get 0 recency credit; this
# year gets full credit. Linear in between.
_RECENCY_WINDOW_YEARS = 25.0
def _citation_signal(citation_count: Optional[int]) -> float:
c = max(0, int(citation_count or 0))
if c <= 0:
return 0.0
return min(1.0, math.log10(c + 1) / math.log10(_CITATION_CAP + 1))
def _recency_signal(year: Optional[int], *, now_year: int = _CURRENT_YEAR) -> float:
if not year or year <= 0:
return 0.0
age = now_year - int(year)
if age <= 0:
return 1.0
if age >= _RECENCY_WINDOW_YEARS:
return 0.0
return 1.0 - (age / _RECENCY_WINDOW_YEARS)
def _label_for(score: float) -> str:
"""Coarse human-/agent-readable band for the heuristic score."""
if score >= 0.6:
return "high"
if score >= 0.35:
return "medium"
return "low"
def compute_relevance(
paper: UnifiedPaperEntity, terms: List[str], *, now_year: int = _CURRENT_YEAR
) -> Dict:
"""Deterministic, query-grounded heuristic relevance for one paper.
Returns a dict (never None): {score, label, method, components}. The score is
in [0, 1]. ``method`` is fixed to "heuristic_v1" and ``is_llm_rcs`` is False
so no consumer confuses it with the human path's LLM RCS.
"""
title_cov = _coverage(terms, paper.title)
abstract_cov = _coverage(terms, paper.abstract)
cite_sig = _citation_signal(paper.citation_count)
rec_sig = _recency_signal(paper.year, now_year=now_year)
score = (
_W_TITLE * title_cov
+ _W_ABSTRACT * abstract_cov
+ _W_CITATION * cite_sig
+ _W_RECENCY * rec_sig
)
score = round(min(1.0, max(0.0, score)), 4)
return {
"score": score,
"label": _label_for(score),
"method": "heuristic_v1",
"is_llm_rcs": False,
"components": {
"title_coverage": round(title_cov, 4),
"abstract_coverage": round(abstract_cov, 4),
"citation_signal": round(cite_sig, 4),
"recency_signal": round(rec_sig, 4),
},
}
# ===========================================================================
# Saturation signal (heuristic, no LLM classification)
# ===========================================================================
#
# Without an LLM classifier we cannot compute the human path's RCS-based
# discovery curve. Instead we expose two cheap, deterministic signals the agent
# can read to judge whether the search was thorough:
#
# 1. Marginal-yield decay across strategies: each retrieval strategy is run in
# sequence; we record how many *new* (previously-unseen) papers each strategy
# contributed. A search that has saturated shows the later strategies adding
# few new papers. We report the per-strategy new-yield and a boolean
# ``looks_saturated`` when the last strategy's marginal new-yield fraction is
# below a small threshold.
# 2. Score distribution: the share of returned papers scoring "high" vs "low".
# A pool dominated by low scores hints the query under-specifies the topic.
#
# This is explicitly ADVISORY (mirrors discovery_curve.py's contract). It never
# truncates or gates results.
_SATURATION_YIELD_THRESHOLD = 0.15 # last strategy adding <15% new -> saturated
def _saturation_signal(
per_strategy_new: List[int],
total_unique: int,
scores: List[float],
*,
query_grounded: bool = True,
) -> Dict:
"""Build the heuristic saturation block.
Args:
per_strategy_new: count of NEW unique papers each strategy contributed,
in strategy order.
total_unique: total unique papers after dedup.
scores: relevance scores of the returned papers.
query_grounded: whether the run had groundable query terms. When False
(e.g. a CJK query that tokenised to nothing, #9) the score
distribution reflects only citation/recency, not topical coverage, so
we mark it not-applicable rather than emit a misleading high/med/low.
"""
last_new = per_strategy_new[-1] if per_strategy_new else 0
# Marginal new-yield fraction of the final strategy relative to the running
# total at that point. If total is tiny, treat as not-saturated (too little
# evidence).
marginal = (last_new / total_unique) if total_unique > 0 else 0.0
looks_saturated = (
total_unique >= 10
and len(per_strategy_new) >= 2
and marginal <= _SATURATION_YIELD_THRESHOLD
)
high = sum(1 for s in scores if s >= 0.6)
medium = sum(1 for s in scores if 0.35 <= s < 0.6)
low = sum(1 for s in scores if s < 0.35)
sig = {
"method": "heuristic_v1",
"advisory": True,
"looks_saturated": bool(looks_saturated),
"per_strategy_new_papers": list(per_strategy_new),
"last_strategy_marginal_yield": round(marginal, 4),
"score_distribution": {"high": high, "medium": medium, "low": low},
"note": (
"Heuristic stop signal (no LLM classification). 'looks_saturated' = "
"later strategies added few new papers. Advisory only."
),
}
if not query_grounded:
# #9: the score bands are meaningless without groundable terms — the score
# only carries citation+recency then. Mark not-applicable (additive keys,
# emitted ONLY on this non-default path -> R-19 default envelope intact).
sig["score_distribution"] = None
sig["score_distribution_note"] = (
"not applicable: the query produced no groundable terms, so relevance "
"scores reflect only citation/recency, not topical coverage."
)
return sig
# ===========================================================================
# Retrieval (reuses openalex_helper / ss_helper.search — no new search logic)
# ===========================================================================
def _resolve_source(config: Config, *, override: Optional[str] = None) -> Tuple[str, bool]:
"""Decide which primary source to use for this run.
Returns (source_used, switched) where source_used is "openalex" or
"semantic_scholar" and ``switched`` notes whether an auto-mode quota fallback
fired.
``override`` (#8) is the transient --primary-source flag: when given it wins
over ``config.primary_source`` for THIS run only (single-use, never persisted),
letting the agent make a per-query 引擎 choice (e.g. AI decides SS for one
query). When None the behaviour is byte-for-byte the config-driven original
(R-19). ``config.quota_fallback`` still governs the auto path either way.
Routing (mirrors the Wave 3b contract):
- primary_source == "semantic_scholar" -> SS.
- primary_source == "auto" -> OpenAlex, but a run-mode quota
probe may stickily fall back to SS when OA USD budget is low (and
quota_fallback is enabled).
- anything else (incl. "openalex") -> OpenAlex (default, unchanged).
"""
primary = (override or getattr(config, "primary_source", "openalex") or "openalex").lower()
if primary == "semantic_scholar":
return "semantic_scholar", False
if primary == "auto" and getattr(config, "quota_fallback", True):
status = quota_guard.evaluate(config, mode="run")
if status.ok and status.should_switch:
return "semantic_scholar", True
return "openalex", False
return "openalex", False
def _retrieve(
query: str,
source: str,
*,
year_min: Optional[int],
year_max: Optional[int],
per_strategy: int,
) -> Tuple[List[List[UnifiedPaperEntity]], List[str]]:
"""Run the multi-strategy retrieval for the chosen source.
Returns (strategy_results, warnings). ``strategy_results`` is a list of
per-strategy entity lists (so the caller can compute marginal yield). We
reuse the *existing* multi-strategy backends rather than reinventing:
- OpenAlex: the three double_sort strategies (cited / recent / relevance),
run individually so we can measure per-strategy yield. (double_sort_search
collapses them; here we call its constituents to keep the marginal-yield
signal — same queries, same sorts, no new logic.)
- Semantic Scholar: ss_helper.search already runs its three bulk strategies
and returns a merged list; we treat that merged list as one "strategy"
for yield purposes since SS does not expose per-strategy splits through
the public search() API. (Documented limitation, not a correctness gap.)
"""
warnings: List[str] = []
if source == "openalex":
strategies = [
("cited_by_count:desc", openalex_helper.search_top_n_pages),
("publication_date:desc", openalex_helper.search_top_n_pages),
("relevance_score:desc", openalex_helper.search_top_n_pages),
]
results: List[List[UnifiedPaperEntity]] = []
for sort, fn in strategies:
try:
batch = fn(query, total_papers=per_strategy, sort=sort, year_min=year_min)
except Exception as exc: # one bad strategy must not kill the run
warnings.append(f"openalex strategy {sort} failed: {exc}")
batch = []
results.append(batch)
return results, warnings
# Semantic Scholar primary.
try:
merged = ss_helper.search(
query, year_min=year_min, year_max=year_max, total_per_strategy=per_strategy
)
except Exception as exc:
warnings.append(f"semantic_scholar search failed: {exc}")
merged = []
if not merged:
warnings.append(
"semantic_scholar returned 0 papers (no key -> 429 on shared pool is "
"the most common cause; set semantic_scholar_api_key)."
)
return [merged], warnings
# ===========================================================================
# Language space (三轴模型 axis 2) — agent-path resolution + Chinese sources
# ===========================================================================
#
# The three orthogonal axes (22_routing_ux_design §3; both routing groups share
# this exact wording):
# - 引擎 primary_source (openalex/SS/auto) = WHO runs the primary retrieval.
# - 语言 search_language (auto/en/zh/both) = WHICH sea to fish in.
# - 补充源 supplemental sources = per-space discipline sources.
#
# Agent-path philosophy (§8): the agent path NEVER asks and NEVER guesses. It has
# no discipline-signal detection (just like it has no PubMed/arXiv auto-routing).
# So the language axis is resolved from EXPLICIT signals only:
# 1. --lang flag -> scope_source "flags" (explicit; wins).
# 2. config.search_language (non-auto) -> "config" (silently adopted, §8).
# 3. auto:
# 3a. no CJK in query -> en, "query_passthrough", not ambiguous
# (this is the byte-identical current behaviour — R-19).
# 3b. CJK in query -> zh, "query_passthrough", ambiguous=True
# (Chinese query hitting OpenAlex/SS already returns Chinese results —
# the agent version of "问一句" is to pass through + flag the ambiguity
# in meta.language so the caller, who has context, decides whether to
# re-run with an explicit --lang. §5.1 4b / §8).
# Supplemental Chinese sources (NSSD 社科 / yiigle 医学) are added ONLY by explicit
# --with-nssd / --with-yiigle (§8: 补充源只认显式 flag). A --with-* on an en scope
# upgrades it to both, so the Chinese query actually reaches the Chinese source
# (§6.6, agent form) — reported via meta.language + a warning.
# CJK / CJK-extension / compatibility-ideograph ranges — a query carrying any of
# these is "Chinese" for the mechanical query_lang signal (matches detect_language
# discipline: script detection is mechanical, everything else is caller judgement).
_CJK_RE = re.compile(f"[{_CJK_CLASS}]")
def _query_has_cjk(text: Optional[str]) -> bool:
"""True when the query contains a CJK ideograph (the mechanical zh signal)."""
return bool(_CJK_RE.search(text or ""))
@dataclass
class _LanguagePlan:
"""Resolved language axis for one agent run (post precedence-merge)."""
query_lang: str = "en" # zh | en — mechanical CJK detection
scope_used: str = "en" # en | zh | both
scope_source: str = "query_passthrough" # flags | config | query_passthrough
ambiguous: bool = False # auto + Chinese query + no explicit signal
chinese_sources: List[str] = None # sources to actually query (--with-*)
engaged: bool = False # whether the language feature is active at all
upgraded_en_to_both: bool = False
def _resolve_language(
query: str,
config: Config,
*,
lang_flag: Optional[str],
with_nssd: bool,
with_yiigle: bool,
) -> _LanguagePlan:
"""Resolve the language axis from explicit signals only (agent path never asks).
``engaged`` is False ONLY on the fully-default path (English query, no --lang,
no --with-*, config auto) — in which case the caller must NOT emit meta.language
so the envelope stays byte-identical to v2.2 (R-19)."""
query_lang = "zh" if _query_has_cjk(query) else "en"
config_lang = (getattr(config, "search_language", "auto") or "auto").lower()
if config_lang not in ("auto", "en", "zh", "both"):
config_lang = "auto"
plan = _LanguagePlan(query_lang=query_lang, chinese_sources=[])
if lang_flag: # 1. explicit flag wins
plan.scope_used = lang_flag
plan.scope_source = "flags"
elif config_lang != "auto": # 2. persisted config value, silently adopted
plan.scope_used = config_lang
plan.scope_source = "config"
else: # 3. auto
plan.scope_source = "query_passthrough"
if query_lang == "en": # 3a — byte-identical current behaviour (R-19)
plan.scope_used = "en"
else: # 3b — Chinese query, no signal: pass through + flag
plan.scope_used = "zh"
plan.ambiguous = True
# --with-* select the Chinese supplemental sources (explicit only, §8).
if with_nssd:
plan.chinese_sources.append("nssd")
if with_yiigle:
plan.chinese_sources.append("yiigle")
# A --with-* on an en scope needs zh present — upgrade en -> both (§6.6).
if plan.chinese_sources and plan.scope_used == "en":
plan.scope_used = "both"
plan.upgraded_en_to_both = True
# The language feature is "engaged" whenever any explicit signal or a Chinese
# query is present. Only the fully-default path leaves it off (R-19: no
# meta.language key, byte-identical envelope).
plan.engaged = bool(
lang_flag
or with_nssd
or with_yiigle
or config_lang != "auto"
or query_lang == "zh"
)
return plan
def _retrieve_chinese(
query: str,
chinese_sources: List[str],
*,
year_min: Optional[int],
year_max: Optional[int],
n: int,
) -> Tuple[List[List[UnifiedPaperEntity]], List[str]]:
"""Query the explicit Chinese supplemental sources and return their result
lists (one per source) for federated dedup, plus warnings.
Each helper (nssd_helper / yiigle_helper, B1) is an independent primary source
emitting the SAME UnifiedPaperEntity shape, so its list folds into
_dedup_with_yield exactly like an openalex/ss strategy. Every helper degrades
gracefully (never raises); we still guard per source so one failing source
never kills the run.
#6: the Chinese helpers only accept ``year_min`` (their forms expose no
reliable year-max param), so we apply the recency CEILING client-side here —
otherwise a Chinese source would ignore ``--year-max`` while the primary engine
honours it. A paper is kept when its year is unknown OR <= year_max (we only
drop papers we can PROVE exceed the ceiling; unknown-year records are left for
the caller to judge, matching how the primary engines surface them)."""
results: List[List[UnifiedPaperEntity]] = []
warnings: List[str] = []
for src in chinese_sources:
try:
if src == "nssd":
batch = nssd_helper.search(query, n=n, year_min=year_min)
elif src == "yiigle":
batch = yiigle_helper.search(query, n=n, year_min=year_min)
else: # pragma: no cover - guarded by the flag parser
continue
except Exception as exc: # helpers shouldn't raise, but never let one kill the run
warnings.append(f"{src} search failed: {exc}")
batch = []
if year_max is not None:
batch = [p for p in batch if p.year is None or p.year <= year_max]
if not batch:
warnings.append(f"{src} returned 0 papers.")
results.append(list(batch))
return results, warnings
def _language_meta(plan: _LanguagePlan) -> Dict:
"""The additive ``meta.language`` block (§8). Only attached when the language
feature is engaged (never on the byte-identical default path — R-19)."""
return {
"query_lang": plan.query_lang, # zh | en (mechanical CJK detection)
"scope_used": plan.scope_used, # en | zh | both
"scope_source": plan.scope_source, # flags | config | query_passthrough
# ambiguous=True is the agent version of the human path's "问一句": auto +
# a Chinese query with no explicit --lang/config signal. The caller (which
# has context the CLI does not) decides whether to re-run with --lang.
"ambiguous": plan.ambiguous,
"chinese_sources_used": list(plan.chinese_sources or []),
}
# ===========================================================================
# Dedup + per-strategy marginal-yield accounting
# ===========================================================================
def _dedup_with_yield(
strategy_results: List[List[UnifiedPaperEntity]],
) -> Tuple[List[UnifiedPaperEntity], List[int], int]:
"""Federate-dedup the strategy results, tracking how many NEW unique papers
each strategy added (for the saturation signal).
Returns (unique_papers_sorted, per_strategy_new, retrieved_raw).
Uses the same federated_dedup the human path uses (R-14: don't reinvent).
"""
from .federated_kg_resolver import canonical_key
retrieved_raw = sum(len(s) for s in strategy_results)
seen_keys: set = set()
per_strategy_new: List[int] = []
for strat in strategy_results:
new_here = 0
for p in strat:
k = canonical_key(p)
if k not in seen_keys:
seen_keys.add(k)
new_here += 1
per_strategy_new.append(new_here)
# Now do the real merge (which also fuses fields across duplicates).
kg = federated_dedup(*strategy_results)
unique = kg_to_list(kg, sort_by="citation_count")
return unique, per_strategy_new, retrieved_raw
# ===========================================================================
# Journal-rank intent resolution (Feature A, Wave A-2)
# ===========================================================================
#
# Resolution precedence (spec §7 — explicit flags win, then NL intent, then the
# config default which only LABELS, never FILTERS):
# 1. explicit flags (--rank-platform / --keep-tiers) — caller meant exactly this.
# 2. NL intent parsed from the query ("中科院一区" -> cas tier 1) — the bug fix.
# 3. config rank.default_platform (factory jcr) — label every paper with this
# platform's slot but DO NOT filter (no tier was requested).
# A bare "Q1" with no platform stays ambiguous: we DO NOT pick a platform; the
# envelope flags it so the calling agent asks the user (spec §7, CLI is non-
# interactive).
@dataclass
class _RankPlan:
"""The resolved journal-rank intent for one run (post precedence-merge)."""
platform: Optional[str] = None # cas | jcr | sjr | None
tiers: Optional[List[int]] = None # CAS 区 numbers
quartiles: Optional[List[str]] = None # JCR/SJR quartiles
top: bool = False
category: Optional[str] = None # pin a sub-category (CAS 小类 / SJR/JCR category)
ambiguous: bool = False # quartile/tier stated but no platform resolvable
candidate_platforms: List[str] = None # for an ambiguous bare quartile
cleaned_query: str = "" # query with rank phrasing stripped (the bug fix)
source: str = "none" # flags | intent | default | none — how we resolved
applied_filter: bool = False # whether a tier/quartile/top filter is active
intent_matched: List[str] = None # raw phrases the parser stripped
def _resolve_rank_plan(
query: str,
config: Config,
*,
rank_platform: Optional[str],
keep_tiers: Optional[List],
rank_category: Optional[str],
) -> _RankPlan:
"""Merge explicit flags + NL intent + config default into one _RankPlan.
The cleaned_query is ALWAYS the intent parser's stripped topic so the rank
phrasing never reaches the search engine (the "中科院一区 被当检索词" fix), even
when the platform itself came from an explicit flag."""
intent = parse_rank_intent(query)
plan = _RankPlan(
cleaned_query=intent.cleaned_query or query,
candidate_platforms=[],
intent_matched=list(intent.matched),
)
# ---- 1. explicit flags take precedence ----
if rank_platform:
plan.platform = rank_platform.lower()
plan.source = "flags"
tiers, quarts = rank_filter.normalize_keep_tiers(plan.platform, keep_tiers or [])
if plan.platform == "cas":
plan.tiers = tiers or None
else:
plan.quartiles = quarts or None
plan.category = rank_category
plan.applied_filter = bool(plan.tiers or plan.quartiles)
return plan
# A keep-tiers flag with no platform flag: still ambiguous unless intent or
# default resolves a platform. Stash it and fall through.
flag_keep = keep_tiers or []
# ---- 2. NL intent ----
if intent.platform:
plan.platform = intent.platform
plan.source = "intent"
plan.tiers = intent.tiers
plan.quartiles = intent.quartiles
plan.top = intent.top
plan.category = rank_category
# A keep-tiers flag refines/overrides the intent's tier list on the same
# platform (explicit flag detail beats parsed phrasing).
if flag_keep:
tiers, quarts = rank_filter.normalize_keep_tiers(plan.platform, flag_keep)
if plan.platform == "cas":
plan.tiers = tiers or plan.tiers
else:
plan.quartiles = quarts or plan.quartiles
plan.applied_filter = bool(plan.tiers or plan.quartiles or plan.top)
return plan
# Intent expressed a tier/quartile/top but no platform -> ambiguous.
if intent.has_filter and intent.ambiguous:
plan.ambiguous = True
plan.quartiles = intent.quartiles
plan.top = intent.top
plan.source = "intent"
plan.candidate_platforms = getattr(intent, "candidate_platforms", None) or ["jcr", "sjr"]
# Do NOT apply a filter without a resolved platform; only label on default.
# ---- 3. config default platform (labels only, never filters) ----
default_platform = "jcr"
rank_cfg = getattr(config, "rank", None)
if isinstance(rank_cfg, dict) and rank_cfg.get("default_platform"):
default_platform = str(rank_cfg["default_platform"]).lower()
if default_platform not in journal_rank.PLATFORMS:
default_platform = "jcr"
plan.platform = default_platform
if plan.source == "none":
plan.source = "default"
plan.category = rank_category
# The default platform NEVER auto-filters (spec §7: 不提分区 → 不过滤). A bare
# keep-tiers flag against the default platform DOES filter, though (the user
# asked for tiers, just didn't name a platform — apply to the default).
if flag_keep and not plan.ambiguous:
tiers, quarts = rank_filter.normalize_keep_tiers(plan.platform, flag_keep)
if plan.platform == "cas":
plan.tiers = tiers or None
else:
plan.quartiles = quarts or None
plan.applied_filter = bool(plan.tiers or plan.quartiles)
plan.source = "flags"
return plan
def _rank_cache_dir(config: Config):
"""Resolve the journal_rank cache dir from config (else built-in default)."""
rank_cfg = getattr(config, "rank", None)
if isinstance(rank_cfg, dict) and rank_cfg.get("cache_dir"):
from pathlib import Path
return Path(str(rank_cfg["cache_dir"])).expanduser()
return None
def _rank_sources(config: Config):
"""Resolve the journal_rank mirror sources from config (else None=built-ins)."""
rank_cfg = getattr(config, "rank", None)
if isinstance(rank_cfg, dict) and isinstance(rank_cfg.get("sources"), dict):
return rank_cfg["sources"]
return None
# ===========================================================================
# Verification (--verify): existence + abstract + cross-source consistency
# ===========================================================================
#
# R1 §4 found citation/abstract verification is the agent's most-needed and
# most error-prone hand-rolled capability (it fears fabricating papers). We give
# it a deterministic, no-LLM check per paper:
#
# - exists: True when the paper has a resolvable identity. We treat a paper as
# "exists" if it has a DOI OR an OpenAlex/SS/PubMed/arXiv id (it came back from
# a real source query, so it exists by construction; the markers below tell the
# agent HOW grounded that existence is).
# - has_doi / has_abstract: explicit booleans so the agent knows when metadata
# it might want to cite is missing.
# - multi_source: True when the federated record was hit by >=2 sources
# (stronger existence evidence).
# - title_year_present: cross-source consistency proxy — we flag records missing
# a title or year (the two fields an agent most often needs to cite).
#
# This is intentionally network-free in its default form: every signal is derived
# from the already-fetched federated entity, so --verify adds no extra API cost
# and cannot fabricate. (A future deeper verify could resolve DOIs over HTTP; the
# B-2 contract only requires the deterministic existence/consistency markers,
# which we provide here.)
def _verify_paper(paper: UnifiedPaperEntity) -> Dict:
has_doi = bool(paper.doi)
has_abstract = bool(paper.abstract and paper.abstract.strip())
n_sources = len(paper.sources or [])
has_any_id = bool(
paper.doi
or paper.openalex_id
or paper.ss_paper_id
or paper.pmid
or paper.arxiv_id
)
flags: List[str] = []
if not has_doi:
flags.append("no_doi")
if not has_abstract:
flags.append("no_abstract")
if not paper.title:
flags.append("no_title")
if not paper.year:
flags.append("no_year")
if n_sources < 2:
flags.append("single_source")
return {
"exists": has_any_id,
"has_doi": has_doi,
"has_abstract": has_abstract,
"multi_source": n_sources >= 2,
"source_count": n_sources,
"title_year_present": bool(paper.title and paper.year),
"flags": flags,
}
def _verify_summary(verifies: List[Dict]) -> Dict:
n = len(verifies)
return {
"total": n,
"with_doi": sum(1 for v in verifies if v["has_doi"]),
"with_abstract": sum(1 for v in verifies if v["has_abstract"]),
"multi_source": sum(1 for v in verifies if v["multi_source"]),
"missing_title_or_year": sum(1 for v in verifies if not v["title_year_present"]),
}
# ===========================================================================
# Reference verification (--verify-refs): direct anti-hallucination entry
# ===========================================================================
#
# R1 §4 found the single most valuable thing an agent needs from this Skill is
# NOT a topic search at all — it is "I already have a list of papers I want to
# cite; tell me which of them actually EXIST so I never cite a hallucinated one."
# The topic-search `--verify` markers only describe results the agent just
# retrieved; they cannot answer "is THIS specific DOI/title real?" without first
# running a full search and hoping the paper turns up in it.
#
# `verify_references` is that direct entry point. Given a list of refs (each a
# DOI and/or a title), it checks each one's EXISTENCE across the free
# authoritative sources (OpenAlex / CrossRef / Semantic Scholar) and returns a
# per-ref ruling plus the canonical metadata of whatever it matched, so the agent
# can both confirm existence and self-correct drifted titles/years/venues.
#
# Contract per ref:
# {
# "ref": {<the input ref, echoed back>},
# "exists": bool, # matched in >=1 authoritative source
# "matched_source": "openalex"|"crossref"|"semantic_scholar"|null,
# "canonical": {title, year, doi, venue} | null, # from the match
# "note": "<human-readable explanation>"
# }
# plus a summary: {total, verified, not_found, by_source, errors}.
#
# Existence policy (deliberate, R-09-style honesty about what each signal means):
# - A ref with a DOI "exists" when ANY source resolves that DOI to a record.
# We prefer OpenAlex (richest canonical), then CrossRef (registry of record
# for DOIs), then SS. The DOI is the strongest existence proof there is.
# - A title-only ref "exists" when an OpenAlex title search returns a record
# whose normalized title matches the input closely (token-set equality on
# the significant tokens). This is necessarily weaker than a DOI hit — a near
# match is reported with a clear note so the agent does not over-trust it.
# - Anything not matched is exists=False with a note. We NEVER fabricate a
# "probably real" verdict — the whole point is to catch hallucinations.
def _normalize_title_tokens(title: Optional[str]) -> frozenset:
"""Significant-token set of a title for fuzzy equality (lowercased, stopwords
and short tokens dropped, deduped). Used to decide whether a title-search hit
is really the same paper as the requested title."""
raw = _TOKEN_RE.findall((title or "").lower())
return frozenset(t for t in raw if len(t) >= 3 and t not in _STOPWORDS)
def _title_match_ratio(requested: Optional[str], candidate: Optional[str]) -> float:
"""Jaccard-style overlap of the two titles' significant token sets, in [0,1].
1.0 = identical significant tokens; used with a threshold so a loosely related
paper is NOT accepted as a match."""
a = _normalize_title_tokens(requested)
b = _normalize_title_tokens(candidate)
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
# A title-search candidate is accepted as "the same paper" at or above this
# token-set overlap. Tuned conservatively: anti-hallucination wants few false
# "exists" verdicts, so we err towards "not found" on a weak match.
_TITLE_MATCH_THRESHOLD = 0.85
def _canonical_from_entity(p: UnifiedPaperEntity) -> Dict:
"""The four fields an agent most needs to confirm/correct a citation."""
return {
"title": p.title or None,