bookmark-research 0.5.0-beta.1
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Score annotated research outputs without pretending to be a semantic judge.
|
|
2
|
+
|
|
3
|
+
The caller supplies a fixed task, gold answers and explicit human/model labels.
|
|
4
|
+
No provider or model is invoked here. Missing semantic annotations yield null
|
|
5
|
+
scores, and synthetic demonstrations are always identified as such.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import statistics
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _object(value, required, optional=(), label="object"):
|
|
15
|
+
if not isinstance(value, dict) or set(value) - set(required) - set(optional) or set(required) - set(value):
|
|
16
|
+
raise ValueError(label + " has missing or unknown fields")
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _text(value, label, maximum=16000):
|
|
21
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\x00" in value:
|
|
22
|
+
raise ValueError(label + " must be a nonempty bounded string")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rows(value, label, maximum=10000):
|
|
27
|
+
if not isinstance(value, list) or len(value) > maximum:
|
|
28
|
+
raise ValueError(label + " must be a bounded list")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ids(value, label):
|
|
33
|
+
rows = _rows(value, label, 50000)
|
|
34
|
+
for item in rows:
|
|
35
|
+
_text(item, label, 300)
|
|
36
|
+
if len(rows) != len(set(rows)):
|
|
37
|
+
raise ValueError(label + " must not contain duplicates")
|
|
38
|
+
return set(rows)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _keyed(rows, key, label, fields, optional=()):
|
|
42
|
+
result = {}
|
|
43
|
+
for row in _rows(rows, label):
|
|
44
|
+
_object(row, fields, optional, label)
|
|
45
|
+
identifier = _text(row[key], label + " id", 300)
|
|
46
|
+
if identifier in result:
|
|
47
|
+
raise ValueError("Duplicate " + label + " id")
|
|
48
|
+
result[identifier] = row
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _number(value, label, nullable=False, maximum=None):
|
|
53
|
+
if value is None and nullable:
|
|
54
|
+
return value
|
|
55
|
+
if type(value) not in (int, float) or not math.isfinite(value) or value < 0 or (maximum is not None and value > maximum):
|
|
56
|
+
raise ValueError(label + " must be a finite nonnegative number")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ratio(numerator, denominator):
|
|
61
|
+
return numerator / denominator if denominator else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fingerprint(value):
|
|
65
|
+
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False).encode()).hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stats(values):
|
|
69
|
+
values = [value for value in values if value is not None]
|
|
70
|
+
return {"n": len(values), "mean": statistics.mean(values) if values else None,
|
|
71
|
+
"sample_variance": statistics.variance(values) if len(values) >= 2 else None,
|
|
72
|
+
"sample_stddev": statistics.stdev(values) if len(values) >= 2 else None,
|
|
73
|
+
"min": min(values) if values else None, "max": max(values) if values else None}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _suite(suite):
|
|
77
|
+
_object(suite, ("schema_version", "suite_id", "kind", "tasks", "rubric"),
|
|
78
|
+
("description", "annotation", "sources"), "suite")
|
|
79
|
+
if type(suite["schema_version"]) is not int or suite["schema_version"] != 1:
|
|
80
|
+
raise ValueError("Unsupported evaluation suite version")
|
|
81
|
+
_text(suite["suite_id"], "suite_id", 300)
|
|
82
|
+
if suite["kind"] not in ("synthetic_fixture", "annotated_benchmark"):
|
|
83
|
+
raise ValueError("suite kind must identify synthetic_fixture or annotated_benchmark")
|
|
84
|
+
rubric = _object(suite["rubric"], ("id", "version", "answer_matching", "citation_support", "report_quality"), label="rubric")
|
|
85
|
+
for key in ("id", "version", "answer_matching", "citation_support"):
|
|
86
|
+
_text(rubric[key], "rubric " + key)
|
|
87
|
+
if not isinstance(rubric["report_quality"], dict) or not 1 <= len(rubric["report_quality"]) <= 20:
|
|
88
|
+
raise ValueError("report_quality must define 1 to 20 rubric dimensions on a 0 to 4 scale")
|
|
89
|
+
for dimension, instruction in rubric["report_quality"].items():
|
|
90
|
+
_text(dimension, "Report dimension", 300)
|
|
91
|
+
_text(instruction, "Report rubric")
|
|
92
|
+
tasks = _keyed(suite["tasks"], "task_id", "task", ("task_id", "prompt", "gold_answers", "scope_source_ids", "question_ids"))
|
|
93
|
+
if not tasks:
|
|
94
|
+
raise ValueError("An evaluation suite needs tasks")
|
|
95
|
+
for task in tasks.values():
|
|
96
|
+
_text(task["prompt"], "Task prompt")
|
|
97
|
+
_ids(task["scope_source_ids"], "Scope source IDs")
|
|
98
|
+
_ids(task["question_ids"], "Question IDs")
|
|
99
|
+
if task["gold_answers"] is not None:
|
|
100
|
+
gold = _keyed(task["gold_answers"], "id", "gold answer", ("id", "text"), ("source_ids", "quote"))
|
|
101
|
+
for answer in gold.values():
|
|
102
|
+
_text(answer["text"], "Gold answer")
|
|
103
|
+
return tasks
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run(run, tasks):
|
|
107
|
+
_object(run, ("run_id", "task_id", "system_id", "mode", "comparison_group", "repeat", "controls", "answers", "claims",
|
|
108
|
+
"citations", "coverage", "measurements", "report"), ("artifacts", "status", "external_run_id"), "run")
|
|
109
|
+
for key in ("run_id", "task_id", "system_id", "mode", "comparison_group"):
|
|
110
|
+
_text(run[key], key, 300)
|
|
111
|
+
if run["task_id"] not in tasks:
|
|
112
|
+
raise ValueError("Run references an unknown task")
|
|
113
|
+
if type(run["repeat"]) is not int or not 1 <= run["repeat"] <= 100000:
|
|
114
|
+
raise ValueError("repeat must be a positive integer")
|
|
115
|
+
if not isinstance(run["report"], str) or len(run["report"]) > 1000000 or "\x00" in run["report"]:
|
|
116
|
+
raise ValueError("report must be bounded text, including an empty failed result")
|
|
117
|
+
if run.get("status", "completed") not in ("completed", "incomplete", "failed"):
|
|
118
|
+
raise ValueError("Run status must be completed, incomplete or failed")
|
|
119
|
+
controls = _object(run["controls"], ("model", "tools", "budget", "input_version", "time_window", "harness_version"), label="controls")
|
|
120
|
+
for field in ("model", "input_version", "time_window", "harness_version"):
|
|
121
|
+
_text(controls[field], "Control " + field, 500)
|
|
122
|
+
_ids(controls["tools"], "Tools")
|
|
123
|
+
if not isinstance(controls["budget"], dict) or len(controls["budget"]) > 30:
|
|
124
|
+
raise ValueError("budget must be a numeric limit mapping")
|
|
125
|
+
for key, value in controls["budget"].items():
|
|
126
|
+
_text(key, "Budget name", 300)
|
|
127
|
+
_number(value, "Budget limit", nullable=True)
|
|
128
|
+
answers = _keyed(run["answers"], "answer_id", "answer", ("answer_id", "text"))
|
|
129
|
+
claims = _keyed(run["claims"], "claim_id", "claim", ("claim_id", "text"))
|
|
130
|
+
for row in (*answers.values(), *claims.values()):
|
|
131
|
+
_text(row["text"], "Answer or claim")
|
|
132
|
+
citations = _keyed(run["citations"], "citation_id", "citation", ("citation_id", "claim_id", "source_id", "quote"),
|
|
133
|
+
("url", "research_id", "source_sha256"))
|
|
134
|
+
pairs = set()
|
|
135
|
+
for citation in citations.values():
|
|
136
|
+
_text(citation["claim_id"], "Citation claim", 300)
|
|
137
|
+
if citation["claim_id"] not in claims:
|
|
138
|
+
raise ValueError("Citation references an unknown claim")
|
|
139
|
+
_text(citation["source_id"], "Citation source", 300)
|
|
140
|
+
_text(citation["quote"], "Citation quote")
|
|
141
|
+
pair = (citation["claim_id"], citation["source_id"])
|
|
142
|
+
if pair in pairs:
|
|
143
|
+
raise ValueError("Deduplicate claim/source citation pairs before evaluation")
|
|
144
|
+
pairs.add(pair)
|
|
145
|
+
coverage = _object(run["coverage"], ("accounted_source_ids", "text_source_ids", "reviewed_source_ids", "answered_question_ids"), label="coverage")
|
|
146
|
+
sets = {key: _ids(value, key) for key, value in coverage.items()}
|
|
147
|
+
task = tasks[run["task_id"]]
|
|
148
|
+
if not (sets["reviewed_source_ids"] <= sets["text_source_ids"] <= sets["accounted_source_ids"] <= set(task["scope_source_ids"])):
|
|
149
|
+
raise ValueError("Coverage must satisfy reviewed <= text <= accounted <= original scope")
|
|
150
|
+
if not sets["answered_question_ids"] <= set(task["question_ids"]):
|
|
151
|
+
raise ValueError("Answered question is outside the original task")
|
|
152
|
+
measurements = _object(run["measurements"], ("cost_usd", "latency_seconds", "basis", "usage"), label="measurements")
|
|
153
|
+
for key in ("cost_usd", "latency_seconds"):
|
|
154
|
+
_number(measurements[key], key, nullable=True)
|
|
155
|
+
if measurements["basis"] not in ("observed", "reported", "synthetic"):
|
|
156
|
+
raise ValueError("Measurement basis must be observed, reported or synthetic")
|
|
157
|
+
if not isinstance(measurements["usage"], dict) or len(measurements["usage"]) > 50:
|
|
158
|
+
raise ValueError("usage must be a numeric measurement mapping")
|
|
159
|
+
for key, value in measurements["usage"].items():
|
|
160
|
+
_text(key, "Usage key", 300)
|
|
161
|
+
_number(value, "Usage measurement", nullable=True)
|
|
162
|
+
return answers, claims, citations
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _judgment(judgment, run, task, rubric):
|
|
166
|
+
if judgment is None:
|
|
167
|
+
return {}, {}, {}, None
|
|
168
|
+
_object(judgment, ("run_id", "rubric_id", "rubric_version", "evaluator", "answer_matches", "citation_labels", "report_scores"), label="judgment")
|
|
169
|
+
if judgment["rubric_id"] != rubric["id"] or judgment["rubric_version"] != rubric["version"]:
|
|
170
|
+
raise ValueError("Judgment must identify the suite rubric and version")
|
|
171
|
+
evaluator = _object(judgment["evaluator"], ("kind", "name", "version"), label="evaluator")
|
|
172
|
+
if evaluator["kind"] not in ("human", "model", "synthetic"):
|
|
173
|
+
raise ValueError("Evaluator kind must be human, model or synthetic")
|
|
174
|
+
_text(evaluator["name"], "Evaluator name", 300)
|
|
175
|
+
_text(evaluator["version"], "Evaluator version", 300)
|
|
176
|
+
answers = _keyed(judgment["answer_matches"], "answer_id", "answer match", ("answer_id", "gold_id"))
|
|
177
|
+
run_answers = {answer["answer_id"] for answer in run["answers"]}
|
|
178
|
+
gold_ids = {answer["id"] for answer in task["gold_answers"] or []}
|
|
179
|
+
if not set(answers) <= run_answers:
|
|
180
|
+
raise ValueError("Answer annotation references a missing prediction")
|
|
181
|
+
for row in answers.values():
|
|
182
|
+
if row["gold_id"] is not None:
|
|
183
|
+
_text(row["gold_id"], "Matched gold id", 300)
|
|
184
|
+
if row["gold_id"] not in gold_ids:
|
|
185
|
+
raise ValueError("Answer annotation references an unknown gold ID")
|
|
186
|
+
citations = _keyed(judgment["citation_labels"], "citation_id", "citation label", ("citation_id", "label", "reason"))
|
|
187
|
+
if not set(citations) <= {citation["citation_id"] for citation in run["citations"]}:
|
|
188
|
+
raise ValueError("Citation judgment references a missing citation")
|
|
189
|
+
for row in citations.values():
|
|
190
|
+
if row["label"] not in ("supported", "unsupported", "uncertain"):
|
|
191
|
+
raise ValueError("Unknown semantic citation label")
|
|
192
|
+
_text(row["reason"], "Citation judgment reason")
|
|
193
|
+
scores = _keyed(judgment["report_scores"], "dimension", "report score", ("dimension", "score", "reason"))
|
|
194
|
+
for row in scores.values():
|
|
195
|
+
if row["dimension"] not in rubric["report_quality"]:
|
|
196
|
+
raise ValueError("Unknown report rubric dimension")
|
|
197
|
+
_number(row["score"], "Report quality score", maximum=4)
|
|
198
|
+
_text(row["reason"], "Report judgment reason")
|
|
199
|
+
return answers, citations, scores, evaluator
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _score(run, task, rubric, shape, judgment):
|
|
203
|
+
answers, claims, citations = shape
|
|
204
|
+
matches, labels, report_scores, evaluator = _judgment(judgment, run, task, rubric)
|
|
205
|
+
gold = task["gold_answers"]
|
|
206
|
+
answer_scored = gold is not None and set(matches) == set(answers)
|
|
207
|
+
true_ids = {match["gold_id"] for match in matches.values() if match["gold_id"] is not None}
|
|
208
|
+
tp = len(true_ids)
|
|
209
|
+
fp = sum(match["gold_id"] is None for match in matches.values())
|
|
210
|
+
fn = len(gold) - tp if gold is not None else None
|
|
211
|
+
precision = tp / (tp + fp) if tp + fp else (1.0 if not gold else 0.0)
|
|
212
|
+
recall = tp / len(gold) if gold else 1.0
|
|
213
|
+
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
|
214
|
+
supported = sum(row["label"] == "supported" for row in labels.values())
|
|
215
|
+
fully_labeled = set(labels) == set(citations)
|
|
216
|
+
semantic_accuracy = _ratio(supported, len(citations)) if fully_labeled else None
|
|
217
|
+
supported_claims = {citations[key]["claim_id"] for key, label in labels.items() if label["label"] == "supported"}
|
|
218
|
+
cited_claims = {row["claim_id"] for row in citations.values()}
|
|
219
|
+
quality_complete = set(report_scores) == set(rubric["report_quality"])
|
|
220
|
+
quality = statistics.mean(row["score"] / 4 for row in report_scores.values()) if quality_complete else None
|
|
221
|
+
source_count, question_count = len(task["scope_source_ids"]), len(task["question_ids"])
|
|
222
|
+
coverage = {"original_source_count": source_count, "original_question_count": question_count}
|
|
223
|
+
coverage_metrics = {}
|
|
224
|
+
for key, prefix in (("accounted_source_ids", "accounted"), ("text_source_ids", "text"), ("reviewed_source_ids", "reviewed"),
|
|
225
|
+
("answered_question_ids", "questions")):
|
|
226
|
+
expected = set(task["question_ids"] if prefix == "questions" else task["scope_source_ids"])
|
|
227
|
+
actual = set(run["coverage"][key])
|
|
228
|
+
coverage[prefix] = {"count": len(actual), "rate": _ratio(len(actual), len(expected)),
|
|
229
|
+
"missing_ids": sorted(expected - actual)[:100], "missing_count": len(expected - actual)}
|
|
230
|
+
coverage_metrics[prefix + "_coverage"] = coverage[prefix]["rate"]
|
|
231
|
+
return {"run_id": run["run_id"], "task_id": run["task_id"], "system_id": run["system_id"], "mode": run["mode"],
|
|
232
|
+
"run_sha256": _fingerprint(run), "judgment_sha256": _fingerprint(judgment) if judgment is not None else None,
|
|
233
|
+
"status": run.get("status", "completed"), "repeat": run["repeat"], "comparison_group": run["comparison_group"],
|
|
234
|
+
"controls": run["controls"], "evaluator": evaluator, "measurements": run["measurements"],
|
|
235
|
+
"metrics": {"answer_precision": precision if answer_scored else None, "answer_recall": recall if answer_scored else None,
|
|
236
|
+
"answer_f1": f1 if answer_scored else None, "semantic_citation_accuracy": semantic_accuracy,
|
|
237
|
+
"semantic_claim_support": _ratio(len(supported_claims), len(claims)) if fully_labeled else None,
|
|
238
|
+
"citation_presence": _ratio(len(cited_claims), len(claims)), "report_quality_normalized": quality,
|
|
239
|
+
"cost_usd": run["measurements"]["cost_usd"], "latency_seconds": run["measurements"]["latency_seconds"],
|
|
240
|
+
**coverage_metrics},
|
|
241
|
+
"answers": {"status": "scored" if answer_scored else "missing_gold" if gold is None else "missing_annotations",
|
|
242
|
+
"gold_count": len(gold) if gold is not None else None, "prediction_count": len(answers), "annotated_count": len(matches),
|
|
243
|
+
"true_positives": tp if answer_scored else None, "false_positives": fp if answer_scored else None,
|
|
244
|
+
"false_negatives": fn if answer_scored else None, "duplicate_gold_matches": len(matches) - tp - fp},
|
|
245
|
+
"citations": {"total": len(citations), "judged": len(labels), "judgment_coverage": _ratio(len(labels), len(citations)),
|
|
246
|
+
"supported": supported, "unsupported": sum(row["label"] == "unsupported" for row in labels.values()),
|
|
247
|
+
"uncertain": sum(row["label"] == "uncertain" for row in labels.values()),
|
|
248
|
+
"accuracy_over_judged": _ratio(supported, len(labels)),
|
|
249
|
+
"semantic_accuracy_requires_all_labels": True},
|
|
250
|
+
"report_quality": {"scored_dimensions": {key: row["score"] for key, row in report_scores.items()},
|
|
251
|
+
"required_dimensions": list(rubric["report_quality"]), "complete": quality_complete},
|
|
252
|
+
"coverage": coverage}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _groups(evaluations):
|
|
256
|
+
groups = {}
|
|
257
|
+
for row in evaluations:
|
|
258
|
+
groups.setdefault((row["task_id"], row["comparison_group"]), []).append(row)
|
|
259
|
+
output = []
|
|
260
|
+
for (task_id, group_id), rows in sorted(groups.items()):
|
|
261
|
+
differences = []
|
|
262
|
+
for key in ("model", "tools", "budget", "input_version", "time_window", "harness_version"):
|
|
263
|
+
values = [sorted(row["controls"][key]) if key == "tools" else row["controls"][key] for row in rows]
|
|
264
|
+
if len({_fingerprint(value) for value in values}) != 1:
|
|
265
|
+
differences.append(key)
|
|
266
|
+
if len({_fingerprint(row["evaluator"]) for row in rows}) != 1:
|
|
267
|
+
differences.append("evaluator")
|
|
268
|
+
if len({row["measurements"]["basis"] for row in rows}) != 1:
|
|
269
|
+
differences.append("measurement_basis")
|
|
270
|
+
if any(not row["controls"]["budget"] or any(value is None for value in row["controls"]["budget"].values()) for row in rows):
|
|
271
|
+
differences.append("budget_not_fully_recorded")
|
|
272
|
+
strata = {}
|
|
273
|
+
for row in rows:
|
|
274
|
+
controls = {**row["controls"], "tools": sorted(row["controls"]["tools"])}
|
|
275
|
+
signature = _fingerprint({"controls": controls, "evaluator": row["evaluator"], "basis": row["measurements"]["basis"]})
|
|
276
|
+
strata.setdefault((row["system_id"], row["mode"], signature), []).append(row)
|
|
277
|
+
systems = []
|
|
278
|
+
for (system_id, mode, signature), repeats in sorted(strata.items()):
|
|
279
|
+
if len({row["repeat"] for row in repeats}) != len(repeats):
|
|
280
|
+
raise ValueError("Duplicate repeat number under the same task, system and controls")
|
|
281
|
+
systems.append({"system_id": system_id, "mode": mode, "condition_id": signature,
|
|
282
|
+
"run_ids": [row["run_id"] for row in repeats], "run_count": len(repeats),
|
|
283
|
+
"failed_runs": sum(row["status"] == "failed" for row in repeats),
|
|
284
|
+
"incomplete_runs": sum(row["status"] == "incomplete" for row in repeats),
|
|
285
|
+
"controls": repeats[0]["controls"], "evaluator": repeats[0]["evaluator"],
|
|
286
|
+
"measurement_basis": repeats[0]["measurements"]["basis"],
|
|
287
|
+
"metrics": {metric: _stats([row["metrics"][metric] for row in repeats]) for metric in repeats[0]["metrics"]}})
|
|
288
|
+
output.append({"task_id": task_id, "comparison_group": group_id,
|
|
289
|
+
"comparability": {"controlled": not differences, "differences": differences,
|
|
290
|
+
"note": "Only task and declared controls are checked; no causal claim or ranking is inferred."},
|
|
291
|
+
"systems": systems})
|
|
292
|
+
return output
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def evaluate(suite, runs, judgments=None):
|
|
296
|
+
"""Evaluate the same task across modes; consume labels, do not invent them.
|
|
297
|
+
|
|
298
|
+
Answer matching is an explicit annotation to gold IDs. Duplicate matches to
|
|
299
|
+
the same gold ID count once for set precision/recall; unmatched predictions
|
|
300
|
+
are false positives. Citation pairs are unique claim/source combinations.
|
|
301
|
+
All-unknown cost/latency stays null. One repeat has no variance estimate.
|
|
302
|
+
"""
|
|
303
|
+
tasks = _suite(suite)
|
|
304
|
+
run_rows = _rows(runs, "runs", 10000)
|
|
305
|
+
shapes, by_id = {}, {}
|
|
306
|
+
for run in run_rows:
|
|
307
|
+
shape = _run(run, tasks)
|
|
308
|
+
if run["run_id"] in by_id:
|
|
309
|
+
raise ValueError("Duplicate evaluation run_id")
|
|
310
|
+
by_id[run["run_id"]], shapes[run["run_id"]] = run, shape
|
|
311
|
+
judge_rows = _rows([] if judgments is None else judgments, "judgments", 10000)
|
|
312
|
+
by_judge = {}
|
|
313
|
+
for judgment in judge_rows:
|
|
314
|
+
if not isinstance(judgment, dict) or not isinstance(judgment.get("run_id"), str):
|
|
315
|
+
raise ValueError("Each judgment needs run_id")
|
|
316
|
+
if judgment["run_id"] not in by_id or judgment["run_id"] in by_judge:
|
|
317
|
+
raise ValueError("Judgment run_id is unknown or duplicated")
|
|
318
|
+
by_judge[judgment["run_id"]] = judgment
|
|
319
|
+
evaluations = [_score(run, tasks[run["task_id"]], suite["rubric"], shapes[run["run_id"]], by_judge.get(run["run_id"]))
|
|
320
|
+
for run in run_rows]
|
|
321
|
+
synthetic = suite["kind"] == "synthetic_fixture" or any(
|
|
322
|
+
row["measurements"]["basis"] == "synthetic" or (row["evaluator"] or {}).get("kind") == "synthetic" for row in evaluations)
|
|
323
|
+
return {"schema_version": 1, "suite_id": suite["suite_id"], "suite_sha256": _fingerprint(suite),
|
|
324
|
+
"runs_sha256": _fingerprint(runs), "judgments_sha256": _fingerprint(judge_rows),
|
|
325
|
+
"kind": "synthetic_demonstration" if synthetic else "annotated_run_evaluation",
|
|
326
|
+
"provider_quality_claim": False, "run_count": len(evaluations), "task_count": len(tasks),
|
|
327
|
+
"evaluated_task_count": len({row["task_id"] for row in evaluations}),
|
|
328
|
+
"rubric": suite["rubric"], "runs": evaluations, "groups": _groups(evaluations),
|
|
329
|
+
"limitations": [
|
|
330
|
+
"Synthetic fixture outputs and labels demonstrate the evaluator; they do not measure any host, model or provider." if synthetic else
|
|
331
|
+
"Scores depend on the supplied gold data, evaluator and declared controls; the program has not independently judged semantics.",
|
|
332
|
+
"Coverage uses reported IDs. Source identity and actual reading require the research evidence audit.",
|
|
333
|
+
"Exact quotes, regex matches and artifact hashes are not semantic citation judgments.",
|
|
334
|
+
"Missing semantic annotations and unknown measurements remain null; partial citation labels are shown separately.",
|
|
335
|
+
"Report quality uses supplied rubric scores. These are not RACE, FACT or public leaderboard scores.",
|
|
336
|
+
"Mean and sample variance describe supplied repeats only; fewer than two observations have no variance estimate."
|
|
337
|
+
]}
|
package/src/jina.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Jina Reader/Search HTTP access; no extra MCP server or model runtime."""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
from remote_mcp import McpError, McpHttpClient
|
|
12
|
+
from service_http import ResearchHttp, _NoRedirect
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JinaClient:
|
|
16
|
+
def __init__(self, headers=None, timeout=30):
|
|
17
|
+
self.headers, self.timeout = dict(headers or {}), timeout
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
self._requests = 0
|
|
20
|
+
|
|
21
|
+
def snapshot_usage(self):
|
|
22
|
+
with self._lock:
|
|
23
|
+
return {"http_requests": self._requests, "tool_calls": self._requests}
|
|
24
|
+
|
|
25
|
+
def _request(self, url, maximum):
|
|
26
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json",
|
|
27
|
+
"User-Agent": "bookmark-research/0.5.0-beta.1", "X-Timeout": str(int(self.timeout)), **self.headers})
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._requests += 1
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.build_opener(_NoRedirect()).open(request, timeout=self.timeout) as response:
|
|
32
|
+
body = response.read(maximum + 1)
|
|
33
|
+
if len(body) > maximum:
|
|
34
|
+
raise McpError("Jina response exceeds size limit", "response_too_large")
|
|
35
|
+
value = json.loads(body, object_pairs_hook=ResearchHttp._unique_object,
|
|
36
|
+
parse_constant=ResearchHttp._invalid_constant, parse_float=ResearchHttp._finite_float)
|
|
37
|
+
if not isinstance(value, dict):
|
|
38
|
+
raise McpError("Jina returned an unrecognized response", "result_format")
|
|
39
|
+
code = value.get("code", 200)
|
|
40
|
+
if type(code) is not int:
|
|
41
|
+
raise McpError("Jina returned an invalid status", "result_format")
|
|
42
|
+
if code >= 400:
|
|
43
|
+
raise self._error(code)
|
|
44
|
+
if "data" not in value:
|
|
45
|
+
raise McpError("Jina returned an unrecognized response", "result_format")
|
|
46
|
+
return value
|
|
47
|
+
except urllib.error.HTTPError as error:
|
|
48
|
+
failure = self._error(error.code, McpHttpClient._retry_after((error.headers or {}).get("Retry-After")))
|
|
49
|
+
if error.fp is not None:
|
|
50
|
+
try:
|
|
51
|
+
error.close()
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
raise failure from None
|
|
55
|
+
except TimeoutError:
|
|
56
|
+
raise McpError("Jina request timed out", "timeout", True) from None
|
|
57
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException):
|
|
58
|
+
raise McpError("Jina connection failed", "connection_error", True) from None
|
|
59
|
+
except (ValueError, UnicodeError):
|
|
60
|
+
raise McpError("Jina response is not valid JSON", "result_format") from None
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _error(status, retry_after=None):
|
|
64
|
+
kind = {401: "authentication_required", 403: "access_denied", 451: "access_denied",
|
|
65
|
+
402: "quota_exhausted", 429: "rate_limited"}.get(status, "http_error")
|
|
66
|
+
return McpError("Jina HTTP %s" % status, kind, status == 429 or status >= 500,
|
|
67
|
+
http_status=status, retry_after=retry_after)
|
|
68
|
+
|
|
69
|
+
def call(self, purpose, query=None, urls=None):
|
|
70
|
+
if purpose == "search":
|
|
71
|
+
if not self.headers.get("Authorization"):
|
|
72
|
+
raise McpError("Jina Search requires JINA_API_KEY", "authentication_required")
|
|
73
|
+
value = self._request("https://s.jina.ai/?" + urlencode({"q": query}), McpHttpClient.MAX_BYTES)
|
|
74
|
+
if not isinstance(value["data"], list):
|
|
75
|
+
raise McpError("Jina Search returned invalid results", "result_format")
|
|
76
|
+
return {"structuredContent": value}
|
|
77
|
+
if purpose != "fetch":
|
|
78
|
+
raise ValueError("Unknown Jina operation")
|
|
79
|
+
|
|
80
|
+
def read(url):
|
|
81
|
+
try:
|
|
82
|
+
# Bound the combined raw response, allowing JSON wrapper overhead.
|
|
83
|
+
value = self._request("https://r.jina.ai/" + url, McpHttpClient.MAX_BYTES // (2 * len(urls)))
|
|
84
|
+
if not isinstance(value["data"], dict):
|
|
85
|
+
raise McpError("Jina Reader returned invalid page data", "result_format")
|
|
86
|
+
return value, None
|
|
87
|
+
except McpError as error:
|
|
88
|
+
return None, {"url": url, **error.details()}
|
|
89
|
+
|
|
90
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(urls))) as pool:
|
|
91
|
+
responses = list(pool.map(read, urls))
|
|
92
|
+
# Keep successful HTTP envelopes intact. A failed sibling is not an
|
|
93
|
+
# isError envelope that would discard other URLs' readable text.
|
|
94
|
+
return {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False)}
|
|
95
|
+
for value, error in responses if value is not None],
|
|
96
|
+
"structuredContent": {"failed_results": [error for value, error in responses if error is not None]}}
|