confoundsearch 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.
- confoundsearch/__init__.py +72 -0
- confoundsearch/__main__.py +5 -0
- confoundsearch/data/__init__.py +2 -0
- confoundsearch/data/corpus.py +36 -0
- confoundsearch/data/es.py +72 -0
- confoundsearch/data/sources.py +148 -0
- confoundsearch/dotenv.py +21 -0
- confoundsearch/errors.py +17 -0
- confoundsearch/http.py +26 -0
- confoundsearch/interface/__init__.py +1 -0
- confoundsearch/interface/cli.py +211 -0
- confoundsearch/interface/export.py +108 -0
- confoundsearch/interface/result.py +63 -0
- confoundsearch/interface/server.py +269 -0
- confoundsearch/interface/skill/SKILL.md +85 -0
- confoundsearch/interface/skill/__init__.py +0 -0
- confoundsearch/interface/ui/__init__.py +12 -0
- confoundsearch/interface/ui/app.html +128 -0
- confoundsearch/interface/ui/render.py +1234 -0
- confoundsearch/logic/__init__.py +2 -0
- confoundsearch/logic/actions/__init__.py +36 -0
- confoundsearch/logic/actions/add_confound.py +21 -0
- confoundsearch/logic/actions/add_edge.py +24 -0
- confoundsearch/logic/actions/attach_passage.py +48 -0
- confoundsearch/logic/actions/enumerate.py +38 -0
- confoundsearch/logic/actions/merge.py +24 -0
- confoundsearch/logic/actions/retrieve.py +32 -0
- confoundsearch/logic/actions/stop.py +17 -0
- confoundsearch/logic/audit.py +435 -0
- confoundsearch/logic/episode.py +113 -0
- confoundsearch/logic/graph.py +171 -0
- confoundsearch/logic/policy.py +111 -0
- confoundsearch/logic/reward.py +28 -0
- confoundsearch/logic/state.py +40 -0
- confoundsearch/logic/transition.py +55 -0
- confoundsearch/model/__init__.py +2 -0
- confoundsearch/model/llm.py +210 -0
- confoundsearch/py.typed +0 -0
- confoundsearch-0.1.0.dist-info/METADATA +98 -0
- confoundsearch-0.1.0.dist-info/RECORD +44 -0
- confoundsearch-0.1.0.dist-info/WHEEL +5 -0
- confoundsearch-0.1.0.dist-info/entry_points.txt +2 -0
- confoundsearch-0.1.0.dist-info/licenses/LICENSE +21 -0
- confoundsearch-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""ConfoundSearch: an agent that builds a confound graph as a Markov Decision Process."""
|
|
2
|
+
|
|
3
|
+
from confoundsearch.data.corpus import Corpus, InMemoryCorpus
|
|
4
|
+
from confoundsearch.data.es import ElasticsearchCorpus
|
|
5
|
+
from confoundsearch.data.sources import OpenAlexCorpus, SemanticScholarCorpus
|
|
6
|
+
from confoundsearch.errors import CorpusError, InvalidAction, LLMError, RemoteError
|
|
7
|
+
from confoundsearch.interface.export import export_zip, save_export, to_share_data
|
|
8
|
+
from confoundsearch.logic.actions import (
|
|
9
|
+
Action,
|
|
10
|
+
AddConfound,
|
|
11
|
+
AddEdge,
|
|
12
|
+
AttachPassage,
|
|
13
|
+
Merge,
|
|
14
|
+
Retrieve,
|
|
15
|
+
Stop,
|
|
16
|
+
enumerate_actions,
|
|
17
|
+
)
|
|
18
|
+
from confoundsearch.logic.audit import AuditedClient, AuditLog, AuditTrail, verify
|
|
19
|
+
from confoundsearch.logic.episode import Step, Trajectory, run_episode
|
|
20
|
+
from confoundsearch.logic.graph import Graph, Passage, Vertex
|
|
21
|
+
from confoundsearch.logic.policy import EpsilonGreedy, LookaheadQ, Softmax
|
|
22
|
+
from confoundsearch.logic.reward import phi, reward
|
|
23
|
+
from confoundsearch.logic.state import State
|
|
24
|
+
from confoundsearch.logic.transition import Environment, Judge
|
|
25
|
+
from confoundsearch.model.llm import LLMJudge, LLMProposer, OpenAICompatibleClient, Proposer
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Action",
|
|
31
|
+
"AddConfound",
|
|
32
|
+
"AddEdge",
|
|
33
|
+
"AttachPassage",
|
|
34
|
+
"AuditLog",
|
|
35
|
+
"AuditTrail",
|
|
36
|
+
"AuditedClient",
|
|
37
|
+
"Corpus",
|
|
38
|
+
"CorpusError",
|
|
39
|
+
"ElasticsearchCorpus",
|
|
40
|
+
"Environment",
|
|
41
|
+
"EpsilonGreedy",
|
|
42
|
+
"Graph",
|
|
43
|
+
"InMemoryCorpus",
|
|
44
|
+
"InvalidAction",
|
|
45
|
+
"Judge",
|
|
46
|
+
"LLMError",
|
|
47
|
+
"LLMJudge",
|
|
48
|
+
"LLMProposer",
|
|
49
|
+
"LookaheadQ",
|
|
50
|
+
"Merge",
|
|
51
|
+
"OpenAICompatibleClient",
|
|
52
|
+
"OpenAlexCorpus",
|
|
53
|
+
"Passage",
|
|
54
|
+
"Proposer",
|
|
55
|
+
"RemoteError",
|
|
56
|
+
"Retrieve",
|
|
57
|
+
"SemanticScholarCorpus",
|
|
58
|
+
"Softmax",
|
|
59
|
+
"State",
|
|
60
|
+
"Step",
|
|
61
|
+
"Stop",
|
|
62
|
+
"Trajectory",
|
|
63
|
+
"Vertex",
|
|
64
|
+
"enumerate_actions",
|
|
65
|
+
"export_zip",
|
|
66
|
+
"phi",
|
|
67
|
+
"reward",
|
|
68
|
+
"run_episode",
|
|
69
|
+
"save_export",
|
|
70
|
+
"to_share_data",
|
|
71
|
+
"verify",
|
|
72
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The corpus D, behind a one-method protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from confoundsearch.logic.graph import Passage, normalize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Corpus(Protocol):
|
|
14
|
+
def search(self, query: str, k: int) -> list[Passage]:
|
|
15
|
+
"""Return up to k passages relevant to the query."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryCorpus:
|
|
20
|
+
"""Ranks passages by how many query words they contain. Deterministic."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, passages: Iterable[Passage]) -> None:
|
|
23
|
+
self.passages = tuple(passages)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_jsonl(cls, path: str | Path) -> InMemoryCorpus:
|
|
27
|
+
"""One JSON object per line with `id` and `text`, and optionally `title` and `url`."""
|
|
28
|
+
rows = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
|
29
|
+
return cls(Passage(str(r["id"]), r["text"], r.get("title", ""), r.get("url", "")) for r in rows)
|
|
30
|
+
|
|
31
|
+
def search(self, query: str, k: int) -> list[Passage]:
|
|
32
|
+
words = set(normalize(query).split())
|
|
33
|
+
scored = [(len(words & set(normalize(p.text).split())), p) for p in self.passages]
|
|
34
|
+
hits = [(n, p) for n, p in scored if n > 0]
|
|
35
|
+
hits.sort(key=lambda hit: (-hit[0], hit[1].id))
|
|
36
|
+
return [p for _, p in hits[: max(k, 0)]]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""An Elasticsearch-backed corpus, over the plain REST API.
|
|
2
|
+
|
|
3
|
+
Works with a local node or Elastic Cloud. Documents need a text field (default
|
|
4
|
+
`abstract`) and may have `title`, `url` and `corpusid`, as in the Semantic
|
|
5
|
+
Scholar index the paper's experiments use.
|
|
6
|
+
|
|
7
|
+
ElasticsearchCorpus("https://my-deployment.es.us-east-1.aws.elastic.cloud", api_key="...")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from confoundsearch.errors import CorpusError, RemoteError
|
|
16
|
+
from confoundsearch.http import Transport, urllib_transport
|
|
17
|
+
from confoundsearch.logic.graph import Passage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ElasticsearchCorpus:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
index: str = "papers",
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
text_field: str = "abstract",
|
|
27
|
+
title_field: str = "title",
|
|
28
|
+
min_citations: int = 0,
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
transport: Transport = urllib_transport,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.url = url.rstrip("/")
|
|
33
|
+
self.index = index
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
self.text_field = text_field
|
|
36
|
+
self.title_field = title_field
|
|
37
|
+
self.min_citations = min_citations
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self.transport = transport
|
|
40
|
+
|
|
41
|
+
def _query(self, query: str, k: int) -> dict[str, Any]:
|
|
42
|
+
match = {"multi_match": {"query": query, "fields": [f"{self.title_field}^2", self.text_field]}}
|
|
43
|
+
bool_query: dict[str, Any] = {"must": match, "filter": [{"exists": {"field": self.text_field}}]}
|
|
44
|
+
if self.min_citations:
|
|
45
|
+
bool_query["filter"].append({"range": {"citationcount": {"gte": self.min_citations}}})
|
|
46
|
+
return {"query": {"bool": bool_query}, "size": k}
|
|
47
|
+
|
|
48
|
+
def search(self, query: str, k: int) -> list[Passage]:
|
|
49
|
+
if k <= 0:
|
|
50
|
+
return []
|
|
51
|
+
headers = {"Content-Type": "application/json"}
|
|
52
|
+
if self.api_key:
|
|
53
|
+
headers["Authorization"] = f"ApiKey {self.api_key}"
|
|
54
|
+
body = json.dumps(self._query(query, k)).encode()
|
|
55
|
+
try:
|
|
56
|
+
raw = self.transport(f"{self.url}/{self.index}/_search", headers, body, self.timeout)
|
|
57
|
+
except RemoteError as err:
|
|
58
|
+
raise CorpusError(str(err)) from err
|
|
59
|
+
try:
|
|
60
|
+
hits = json.loads(raw)["hits"]["hits"]
|
|
61
|
+
return [self._passage(hit) for hit in hits]
|
|
62
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
63
|
+
raise CorpusError(f"unexpected response: {raw[:200]!r}") from err
|
|
64
|
+
|
|
65
|
+
def _passage(self, hit: dict[str, Any]) -> Passage:
|
|
66
|
+
source = hit["_source"]
|
|
67
|
+
return Passage(
|
|
68
|
+
id=str(source.get("corpusid", hit["_id"])),
|
|
69
|
+
text=source[self.text_field],
|
|
70
|
+
title=source.get(self.title_field) or "",
|
|
71
|
+
url=source.get("url") or "",
|
|
72
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Paper sources that need no corpus of your own: Semantic Scholar's API and OpenAlex's.
|
|
2
|
+
|
|
3
|
+
Both implement the same one-method `Corpus` protocol as the file and Elasticsearch corpora, so the agent
|
|
4
|
+
does not know or care which it is searching. Only papers that have an abstract are returned, because an
|
|
5
|
+
abstract is the passage the agent reads and cites.
|
|
6
|
+
|
|
7
|
+
SemanticScholarCorpus(api_key="...") # a free key raises the rate limit; request one at S2_KEY_URL
|
|
8
|
+
OpenAlexCorpus(mailto="you@example.org") # an email (or an api_key) puts you in OpenAlex's polite pool
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
from confoundsearch.errors import CorpusError, RemoteError
|
|
19
|
+
from confoundsearch.http import Transport, urllib_transport
|
|
20
|
+
from confoundsearch.logic.graph import Passage
|
|
21
|
+
|
|
22
|
+
MAX_PAGE = 100
|
|
23
|
+
S2_KEY_URL = "https://www.semanticscholar.org/product/api" # where to request a free Semantic Scholar API key
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get(transport: Transport, url: str, headers: Mapping[str, str], timeout: float, hint: str) -> Any:
|
|
27
|
+
try:
|
|
28
|
+
raw = transport(url, headers, None, timeout)
|
|
29
|
+
except RemoteError as err:
|
|
30
|
+
extra = f" {hint}" if "HTTP 429" in str(err) else ""
|
|
31
|
+
raise CorpusError(f"{err}{extra}") from err
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(raw)
|
|
34
|
+
except ValueError as err:
|
|
35
|
+
raise CorpusError(f"unexpected response: {raw[:200]!r}") from err
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _page_size(k: int) -> int:
|
|
39
|
+
"""Ask for extra results, since some papers have no abstract and are dropped."""
|
|
40
|
+
return min(max(2 * k, k), MAX_PAGE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SemanticScholarCorpus:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
base_url: str = "https://api.semanticscholar.org/graph/v1",
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
transport: Transport = urllib_transport,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.api_key = api_key
|
|
52
|
+
self.base_url = base_url.rstrip("/")
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.transport = transport
|
|
55
|
+
|
|
56
|
+
def search(self, query: str, k: int) -> list[Passage]:
|
|
57
|
+
if k <= 0 or not query.strip():
|
|
58
|
+
return []
|
|
59
|
+
params = urlencode(
|
|
60
|
+
{
|
|
61
|
+
"query": query,
|
|
62
|
+
"limit": _page_size(k),
|
|
63
|
+
"fields": "title,abstract,url,year,citationCount,corpusId",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
headers = {"Accept": "application/json"}
|
|
67
|
+
if self.api_key:
|
|
68
|
+
headers["x-api-key"] = self.api_key
|
|
69
|
+
hint = (
|
|
70
|
+
f"Semantic Scholar is rate limiting you. Request a free API key at {S2_KEY_URL} "
|
|
71
|
+
"and set it as S2_KEY (or --s2-key); it raises the limit."
|
|
72
|
+
)
|
|
73
|
+
data = _get(self.transport, f"{self.base_url}/paper/search?{params}", headers, self.timeout, hint)
|
|
74
|
+
try:
|
|
75
|
+
papers = data.get("data") or [] # an empty result has no "data" at all
|
|
76
|
+
passages = [self._passage(p) for p in papers if p.get("abstract")]
|
|
77
|
+
except (AttributeError, KeyError, TypeError) as err:
|
|
78
|
+
raise CorpusError(f"unexpected response: {str(data)[:200]!r}") from err
|
|
79
|
+
return passages[:k]
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _passage(paper: dict[str, Any]) -> Passage:
|
|
83
|
+
return Passage(
|
|
84
|
+
id=str(paper.get("corpusId") or paper["paperId"]),
|
|
85
|
+
text=paper["abstract"],
|
|
86
|
+
title=paper.get("title") or "",
|
|
87
|
+
url=paper.get("url") or "",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def abstract_from_inverted_index(inverted: Mapping[str, list[int]]) -> str:
|
|
92
|
+
"""OpenAlex stores an abstract as {word: [positions]}. Put the words back in order."""
|
|
93
|
+
placed = sorted((position, word) for word, positions in inverted.items() for position in positions)
|
|
94
|
+
return " ".join(word for _, word in placed)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenAlexCorpus:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: str | None = None,
|
|
101
|
+
mailto: str | None = None,
|
|
102
|
+
base_url: str = "https://api.openalex.org",
|
|
103
|
+
timeout: float = 30.0,
|
|
104
|
+
transport: Transport = urllib_transport,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.api_key = api_key
|
|
107
|
+
self.mailto = mailto
|
|
108
|
+
self.base_url = base_url.rstrip("/")
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self.transport = transport
|
|
111
|
+
|
|
112
|
+
def search(self, query: str, k: int) -> list[Passage]:
|
|
113
|
+
if k <= 0 or not query.strip():
|
|
114
|
+
return []
|
|
115
|
+
params = {
|
|
116
|
+
"search": query,
|
|
117
|
+
"per_page": _page_size(k),
|
|
118
|
+
"select": "id,title,abstract_inverted_index,doi,publication_year",
|
|
119
|
+
}
|
|
120
|
+
if self.api_key:
|
|
121
|
+
params["api_key"] = self.api_key
|
|
122
|
+
if self.mailto:
|
|
123
|
+
params["mailto"] = self.mailto
|
|
124
|
+
hint = (
|
|
125
|
+
"OpenAlex is rate limiting you; add an email (OPENALEX_EMAIL) or an API key (OPENALEX_API_KEY)."
|
|
126
|
+
)
|
|
127
|
+
data = _get(
|
|
128
|
+
self.transport,
|
|
129
|
+
f"{self.base_url}/works?{urlencode(params)}",
|
|
130
|
+
{"Accept": "application/json"},
|
|
131
|
+
self.timeout,
|
|
132
|
+
hint,
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
works = data["results"]
|
|
136
|
+
passages = [self._passage(w) for w in works if w.get("abstract_inverted_index")]
|
|
137
|
+
except (KeyError, TypeError, AttributeError) as err:
|
|
138
|
+
raise CorpusError(f"unexpected response: {str(data)[:200]!r}") from err
|
|
139
|
+
return passages[:k]
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _passage(work: dict[str, Any]) -> Passage:
|
|
143
|
+
return Passage(
|
|
144
|
+
id=work["id"].rsplit("/", 1)[-1],
|
|
145
|
+
text=abstract_from_inverted_index(work["abstract_inverted_index"]),
|
|
146
|
+
title=work.get("title") or "",
|
|
147
|
+
url=work.get("doi") or work["id"],
|
|
148
|
+
)
|
confoundsearch/dotenv.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Read settings from a `.env` file, so API keys need not be typed into a terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_dotenv(path: Path) -> None:
|
|
10
|
+
"""Put KEY=VALUE lines from `path` into the environment. Never overrides a variable already set."""
|
|
11
|
+
if not path.is_file():
|
|
12
|
+
return
|
|
13
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
14
|
+
line = raw.strip().removeprefix("export ")
|
|
15
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
16
|
+
continue
|
|
17
|
+
key, _, value = line.partition("=")
|
|
18
|
+
value = value.strip()
|
|
19
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
20
|
+
value = value[1:-1]
|
|
21
|
+
os.environ.setdefault(key.strip(), value)
|
confoundsearch/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions shared across the package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidAction(ValueError):
|
|
5
|
+
"""An edit or action violates a graph invariant or the MDP's rules."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RemoteError(RuntimeError):
|
|
9
|
+
"""A remote HTTP service failed or returned something unusable."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMError(RemoteError):
|
|
13
|
+
"""An OpenAI-compatible endpoint failed or returned something unusable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CorpusError(RemoteError):
|
|
17
|
+
"""The search backend failed or returned something unusable."""
|
confoundsearch/http.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""One HTTP request (a POST with a body, or a GET without), using only the standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
|
|
9
|
+
from confoundsearch.errors import RemoteError
|
|
10
|
+
|
|
11
|
+
Transport = Callable[[str, Mapping[str, str], bytes | None, float], bytes]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def urllib_transport(url: str, headers: Mapping[str, str], body: bytes | None, timeout: float) -> bytes:
|
|
15
|
+
request = urllib.request.Request(
|
|
16
|
+
url, data=body, headers=dict(headers), method="GET" if body is None else "POST"
|
|
17
|
+
)
|
|
18
|
+
try:
|
|
19
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
20
|
+
data: bytes = response.read()
|
|
21
|
+
return data
|
|
22
|
+
except urllib.error.HTTPError as err:
|
|
23
|
+
detail = err.read().decode("utf-8", "replace")
|
|
24
|
+
raise RemoteError(f"HTTP {err.code} from {url}: {detail}") from err
|
|
25
|
+
except urllib.error.URLError as err:
|
|
26
|
+
raise RemoteError(f"could not reach {url}: {err.reason}") from err
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The interface layer: the web page and its backend, the command line, the JSON result, the agent skill."""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Command line: `confoundsearch serve` runs the interface and its backend,
|
|
2
|
+
`confoundsearch run` runs one search and writes the standalone page.
|
|
3
|
+
|
|
4
|
+
confoundsearch serve --model gpt-4.1 # the hosted index (ES_URL, ES_API_KEY)
|
|
5
|
+
confoundsearch serve --model gpt-4.1 --corpus openalex # or semanticscholar, or --corpus-file F
|
|
6
|
+
confoundsearch run --iv "IT investment" --dv "firm performance" --corpus-file passages.jsonl
|
|
7
|
+
confoundsearch audit confoundsearch.logic.audit.jsonl # check and read a run's audit trail
|
|
8
|
+
confoundsearch run --json ... # the result as JSON, for programs and agents
|
|
9
|
+
confoundsearch install-skill # add the Claude Code skill
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from importlib import resources
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from confoundsearch import __version__
|
|
24
|
+
from confoundsearch.data.corpus import Corpus, InMemoryCorpus
|
|
25
|
+
from confoundsearch.data.es import ElasticsearchCorpus
|
|
26
|
+
from confoundsearch.data.sources import OpenAlexCorpus, SemanticScholarCorpus
|
|
27
|
+
from confoundsearch.dotenv import load_dotenv
|
|
28
|
+
from confoundsearch.interface.export import to_share_data
|
|
29
|
+
from confoundsearch.interface.result import run_result
|
|
30
|
+
from confoundsearch.interface.server import RunManager, make_server
|
|
31
|
+
from confoundsearch.interface.ui import standalone_html
|
|
32
|
+
from confoundsearch.logic.audit import AuditedClient, AuditLog, AuditTrail
|
|
33
|
+
from confoundsearch.logic.episode import ActionSource, run_episode
|
|
34
|
+
from confoundsearch.logic.policy import EpsilonGreedy, LookaheadQ
|
|
35
|
+
from confoundsearch.logic.transition import Environment
|
|
36
|
+
from confoundsearch.model.llm import OPENROUTER_URL, LLMJudge, LLMProposer, OpenAICompatibleClient, Proposer
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(prog="confoundsearch", description=__doc__.split("\n\n")[0])
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
42
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
45
|
+
llm = shared.add_argument_group("language model (any OpenAI-compatible endpoint)")
|
|
46
|
+
llm.add_argument("--model", required=True)
|
|
47
|
+
llm.add_argument("--base-url", help="default: $OPENAI_BASE_URL or https://api.openai.com/v1")
|
|
48
|
+
llm.add_argument("--api-key", help="default: $OPENAI_API_KEY (with --openrouter: $OPENROUTER_API_KEY)")
|
|
49
|
+
llm.add_argument(
|
|
50
|
+
"--openrouter", action="store_true", help="use OpenRouter; --model is a slug like openai/gpt-4.1"
|
|
51
|
+
)
|
|
52
|
+
env = os.environ.get
|
|
53
|
+
papers = shared.add_argument_group("papers to search (default: the hosted Elasticsearch index)")
|
|
54
|
+
papers.add_argument(
|
|
55
|
+
"--corpus", choices=CORPORA, help="elasticsearch (default), semanticscholar, openalex, or file"
|
|
56
|
+
)
|
|
57
|
+
papers.add_argument("--corpus-file", type=Path, help="JSONL with id, text, and optionally title, url")
|
|
58
|
+
papers.add_argument("--es-url", default=env("ES_URL"), help="Elasticsearch endpoint (default: $ES_URL)")
|
|
59
|
+
papers.add_argument("--es-index", default="papers")
|
|
60
|
+
papers.add_argument("--es-api-key", default=env("ES_API_KEY"), help="default: $ES_API_KEY")
|
|
61
|
+
papers.add_argument(
|
|
62
|
+
"--s2-key", default=env("S2_KEY"), help="Semantic Scholar API key (optional; $S2_KEY)"
|
|
63
|
+
)
|
|
64
|
+
papers.add_argument(
|
|
65
|
+
"--openalex-key", default=env("OPENALEX_API_KEY"), help="OpenAlex API key ($OPENALEX_API_KEY)"
|
|
66
|
+
)
|
|
67
|
+
papers.add_argument(
|
|
68
|
+
"--openalex-email", default=env("OPENALEX_EMAIL"), help="email for OpenAlex ($OPENALEX_EMAIL)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
serve = commands.add_parser("serve", parents=[shared], help="serve the interface and its backend")
|
|
72
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
73
|
+
serve.add_argument("--port", type=int, default=7860)
|
|
74
|
+
serve.add_argument("--max-concurrent", type=int, default=2)
|
|
75
|
+
serve.add_argument("--audit-dir", type=Path, help="also write every run's audit trail to this folder")
|
|
76
|
+
|
|
77
|
+
run = commands.add_parser("run", parents=[shared], help="run one search and write the standalone page")
|
|
78
|
+
run.add_argument("--iv", required=True)
|
|
79
|
+
run.add_argument("--dv", required=True)
|
|
80
|
+
run.add_argument("--max-steps", type=int, default=40)
|
|
81
|
+
run.add_argument("--epsilon", type=float, default=0.1)
|
|
82
|
+
run.add_argument("--seed", type=int)
|
|
83
|
+
run.add_argument("--out", type=Path, default=Path("confoundsearch.html"))
|
|
84
|
+
run.add_argument("--audit", type=Path, help="where to write the audit trail (default: next to --out)")
|
|
85
|
+
run.add_argument(
|
|
86
|
+
"--json", action="store_true", help="print the result as JSON (and nothing else) to stdout"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
audit = commands.add_parser("audit", help="check and read an audit trail written by a run")
|
|
90
|
+
audit.add_argument("file", type=Path, help="an audit trail (.jsonl)")
|
|
91
|
+
audit.add_argument(
|
|
92
|
+
"--json", action="store_true", help="print a machine-readable summary instead of the report"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
skill = commands.add_parser("install-skill", help="install the Claude Code skill for ConfoundSearch")
|
|
96
|
+
skill.add_argument("--dir", type=Path, help="skills folder (default: ~/.claude/skills)")
|
|
97
|
+
skill.add_argument("--project", action="store_true", help="install into ./.claude/skills instead")
|
|
98
|
+
skill.add_argument("--force", action="store_true", help="overwrite a different SKILL.md")
|
|
99
|
+
skill.add_argument(
|
|
100
|
+
"--print", dest="show", action="store_true", help="print the skill instead of installing it"
|
|
101
|
+
)
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
CORPORA = ("elasticsearch", "semanticscholar", "openalex", "file")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_corpus(args: argparse.Namespace) -> Corpus:
|
|
109
|
+
"""The papers to search. With nothing chosen, this is the hosted Elasticsearch index."""
|
|
110
|
+
kind = args.corpus or ("file" if args.corpus_file else "elasticsearch")
|
|
111
|
+
if args.corpus_file and kind != "file":
|
|
112
|
+
raise SystemExit("error: --corpus-file goes with --corpus file, not with another source")
|
|
113
|
+
if kind == "file":
|
|
114
|
+
if not args.corpus_file:
|
|
115
|
+
raise SystemExit("error: --corpus file needs --corpus-file PATH")
|
|
116
|
+
return InMemoryCorpus.from_jsonl(args.corpus_file)
|
|
117
|
+
if kind == "semanticscholar":
|
|
118
|
+
return SemanticScholarCorpus(args.s2_key)
|
|
119
|
+
if kind == "openalex":
|
|
120
|
+
return OpenAlexCorpus(args.openalex_key, args.openalex_email)
|
|
121
|
+
if not args.es_url:
|
|
122
|
+
raise SystemExit(
|
|
123
|
+
"error: the hosted Elasticsearch index needs ES_URL and ES_API_KEY (put them in a .env file), "
|
|
124
|
+
"or pick another source: --corpus semanticscholar, --corpus openalex, or --corpus-file PATH"
|
|
125
|
+
)
|
|
126
|
+
return ElasticsearchCorpus(args.es_url, args.es_index, args.es_api_key)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_client(args: argparse.Namespace) -> OpenAICompatibleClient:
|
|
130
|
+
"""The AI model. With --openrouter it is OpenRouter, using your own OPENROUTER_API_KEY."""
|
|
131
|
+
if not args.openrouter:
|
|
132
|
+
return OpenAICompatibleClient(args.model, base_url=args.base_url, api_key=args.api_key)
|
|
133
|
+
key = args.api_key or os.environ.get("OPENROUTER_API_KEY")
|
|
134
|
+
if not key:
|
|
135
|
+
raise SystemExit("error: --openrouter needs OPENROUTER_API_KEY (put it in a .env file) or --api-key")
|
|
136
|
+
return OpenAICompatibleClient(args.model, base_url=args.base_url or OPENROUTER_URL, api_key=key)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_backend(args: argparse.Namespace) -> tuple[Environment, ActionSource]:
|
|
140
|
+
client = AuditedClient(build_client(args))
|
|
141
|
+
corpus = build_corpus(args)
|
|
142
|
+
return Environment(corpus, LLMJudge(client)), Proposer(LLMProposer(client))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def skill_text() -> str:
|
|
146
|
+
return resources.files("confoundsearch.interface.skill").joinpath("SKILL.md").read_text(encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def install_skill(args: argparse.Namespace) -> int:
|
|
150
|
+
"""Copy the packaged SKILL.md to <skills folder>/confoundsearch/SKILL.md, where Claude Code finds it."""
|
|
151
|
+
text = skill_text()
|
|
152
|
+
if args.show:
|
|
153
|
+
print(text, end="")
|
|
154
|
+
return 0
|
|
155
|
+
default = Path.cwd() / ".claude" / "skills" if args.project else Path.home() / ".claude" / "skills"
|
|
156
|
+
target = (args.dir or default) / "confoundsearch" / "SKILL.md"
|
|
157
|
+
if target.exists():
|
|
158
|
+
if target.read_text(encoding="utf-8") == text:
|
|
159
|
+
print(f"already installed: {target}")
|
|
160
|
+
return 0
|
|
161
|
+
if not args.force:
|
|
162
|
+
print(f"error: {target} exists and differs; use --force to overwrite it", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
target.write_text(text, encoding="utf-8")
|
|
166
|
+
print(f"installed {target}")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
171
|
+
load_dotenv(Path.cwd() / ".env") # before the parser reads its defaults from the environment
|
|
172
|
+
args = build_parser().parse_args(argv)
|
|
173
|
+
if args.command == "install-skill":
|
|
174
|
+
return install_skill(args)
|
|
175
|
+
if args.command == "audit":
|
|
176
|
+
trail = AuditTrail.load(args.file)
|
|
177
|
+
if args.json:
|
|
178
|
+
print(json.dumps({"file": str(args.file), **trail.to_dict()}, indent=2))
|
|
179
|
+
else:
|
|
180
|
+
print(trail.to_markdown(), end="")
|
|
181
|
+
return 0 if trail.intact and trail.complete else 1
|
|
182
|
+
env, proposer = build_backend(args)
|
|
183
|
+
if args.command == "serve":
|
|
184
|
+
server = make_server(
|
|
185
|
+
RunManager(env, proposer, args.max_concurrent, audit_dir=args.audit_dir), args.host, args.port
|
|
186
|
+
)
|
|
187
|
+
print(f"ConfoundSearch at http://{args.host}:{server.server_port}", file=sys.stderr)
|
|
188
|
+
try:
|
|
189
|
+
server.serve_forever()
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
pass
|
|
192
|
+
finally:
|
|
193
|
+
server.server_close()
|
|
194
|
+
return 0
|
|
195
|
+
policy = EpsilonGreedy(
|
|
196
|
+
LookaheadQ(env.preview), args.epsilon, random.Random(args.seed), explore_stop=False
|
|
197
|
+
)
|
|
198
|
+
audit_path = args.audit or args.out.with_name(f"{args.out.stem}.audit.jsonl")
|
|
199
|
+
with AuditLog(audit_path) as log:
|
|
200
|
+
trajectory = run_episode(env, policy, proposer, args.iv, args.dv, args.max_steps, audit=log)
|
|
201
|
+
args.out.write_text(standalone_html(to_share_data(trajectory.final)), encoding="utf-8")
|
|
202
|
+
if args.json:
|
|
203
|
+
print(json.dumps(run_result(trajectory, args.out, audit_path), indent=2))
|
|
204
|
+
return 0
|
|
205
|
+
graph = trajectory.final.graph
|
|
206
|
+
print(f"{len(trajectory.steps)} steps, {len(graph.connected())} connected, phi={trajectory.score:.3f}")
|
|
207
|
+
print(f"wrote {args.out}")
|
|
208
|
+
trail = trajectory.audit_trail()
|
|
209
|
+
chain = "intact" if trail.intact else "BROKEN"
|
|
210
|
+
print(f"audit trail {audit_path} ({len(trail.events)} events, hash chain {chain})")
|
|
211
|
+
return 0
|