@apmantza/greedysearch-pi 1.9.2 → 2.1.2
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/CHANGELOG.md +132 -2
- package/README.md +82 -47
- package/bin/cdp.mjs +1153 -1108
- package/bin/launch.mjs +9 -0
- package/bin/search.mjs +318 -81
- package/extractors/bing-copilot.mjs +48 -18
- package/extractors/chatgpt.mjs +553 -0
- package/extractors/common.mjs +213 -22
- package/extractors/consensus.mjs +655 -0
- package/extractors/consent.mjs +182 -18
- package/extractors/gemini.mjs +350 -217
- package/extractors/google-ai.mjs +129 -128
- package/extractors/logically.mjs +629 -0
- package/extractors/perplexity.mjs +547 -217
- package/extractors/selectors.mjs +3 -2
- package/extractors/semantic-scholar.mjs +219 -0
- package/package.json +8 -4
- package/skills/greedy-search/skill.md +20 -12
- package/src/fetcher.mjs +23 -1
- package/src/formatters/results.ts +185 -128
- package/src/search/browser-lifecycle.mjs +27 -5
- package/src/search/challenge-detect.mjs +205 -0
- package/src/search/chrome.mjs +653 -590
- package/src/search/constants.mjs +155 -39
- package/src/search/engines.mjs +114 -76
- package/src/search/fetch-source.mjs +566 -451
- package/src/search/pdf.mjs +68 -0
- package/src/search/progress.mjs +145 -0
- package/src/search/recovery.mjs +73 -45
- package/src/search/research.mjs +1419 -62
- package/src/search/scale-aware.mjs +93 -0
- package/src/search/simple-research.mjs +520 -0
- package/src/search/sources.mjs +52 -22
- package/src/search/synthesis-runner.mjs +105 -26
- package/src/search/synthesis.mjs +286 -246
- package/src/tools/greedy-search-handler.ts +129 -59
- package/src/tools/shared.ts +312 -186
- package/src/types.ts +110 -104
- package/test.mjs +537 -18
package/src/search/research.mjs
CHANGED
|
@@ -6,23 +6,56 @@
|
|
|
6
6
|
// no-API browser engines and source fetchers instead of Firecrawl/OpenAI.
|
|
7
7
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import {
|
|
12
13
|
buildSourceRegistry,
|
|
14
|
+
classifySourceType,
|
|
13
15
|
computeCompositeScore,
|
|
14
16
|
mergeFetchDataIntoSources,
|
|
15
17
|
normalizeUrl,
|
|
16
18
|
trimText,
|
|
17
19
|
} from "./sources.mjs";
|
|
18
20
|
import { parseStructuredJson } from "./synthesis.mjs";
|
|
21
|
+
import { RESEARCH_ENGINES } from "./constants.mjs";
|
|
19
22
|
import { runGeminiPrompt } from "./synthesis-runner.mjs";
|
|
23
|
+
import { classifyResearchComplexity } from "./scale-aware.mjs";
|
|
24
|
+
import { runSimpleResearchMode } from "./simple-research.mjs";
|
|
25
|
+
import { createProgressTracker } from "./progress.mjs";
|
|
20
26
|
|
|
21
27
|
const __dir = fileURLToPath(new URL(".", import.meta.url)).replace(
|
|
22
28
|
/^\/([A-Z]:)/,
|
|
23
29
|
"$1",
|
|
24
30
|
);
|
|
25
31
|
const SEARCH_BIN = join(__dir, "..", "..", "bin", "search.mjs");
|
|
32
|
+
const DEFAULT_RESEARCH_BUNDLE_ROOT = join(
|
|
33
|
+
process.cwd(),
|
|
34
|
+
".pi",
|
|
35
|
+
"greedysearch-research",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function slugifyResearchName(value) {
|
|
39
|
+
const slug = String(value || "research")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replaceAll(/^-|-$/g, "")
|
|
43
|
+
.slice(0, 60);
|
|
44
|
+
return slug || "research";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function uniqueStrings(items, limit = Infinity) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const item of items || []) {
|
|
51
|
+
const clean = trimText(String(item || ""), 1000);
|
|
52
|
+
if (!clean || seen.has(clean)) continue;
|
|
53
|
+
seen.add(clean);
|
|
54
|
+
out.push(clean);
|
|
55
|
+
if (out.length >= limit) break;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
26
59
|
|
|
27
60
|
async function fetchMultipleResearchSources(...args) {
|
|
28
61
|
const { fetchMultipleSources } = await import("./fetch-source.mjs");
|
|
@@ -327,22 +360,28 @@ export function buildFallbackQueriesFromGaps(
|
|
|
327
360
|
) {
|
|
328
361
|
const fallbacks = [];
|
|
329
362
|
const angles = [
|
|
330
|
-
{ template: (g) => `${g} official documentation`, label: "official docs" },
|
|
331
363
|
{
|
|
332
|
-
template: (
|
|
364
|
+
template: (gap) => `${gap} official documentation`,
|
|
365
|
+
label: "official docs",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
template: (gap) => `${gap} GitHub issues discussions`,
|
|
333
369
|
label: "community signals",
|
|
334
370
|
},
|
|
335
371
|
{
|
|
336
|
-
template: (
|
|
372
|
+
template: (gap) => `${gap} benchmarks performance comparison`,
|
|
337
373
|
label: "benchmarks",
|
|
338
374
|
},
|
|
339
|
-
{ template: (g) => `${g} limitations risks caveats`, label: "limitations" },
|
|
340
375
|
{
|
|
341
|
-
template: (
|
|
376
|
+
template: (gap) => `${gap} limitations risks caveats`,
|
|
377
|
+
label: "limitations",
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
template: (gap) => `${gap} production deployment experience`,
|
|
342
381
|
label: "production usage",
|
|
343
382
|
},
|
|
344
383
|
{
|
|
345
|
-
template: (
|
|
384
|
+
template: (gap) => `${originalQuery} ${gap} counter evidence`,
|
|
346
385
|
label: "counter-evidence",
|
|
347
386
|
},
|
|
348
387
|
];
|
|
@@ -350,7 +389,7 @@ export function buildFallbackQueriesFromGaps(
|
|
|
350
389
|
for (let i = 0; i < gaps.length && fallbacks.length < nextBreadth; i++) {
|
|
351
390
|
const gap = gaps[i];
|
|
352
391
|
const angle = angles[i % angles.length];
|
|
353
|
-
const candidate = angle.template(
|
|
392
|
+
const candidate = angle.template(gap);
|
|
354
393
|
if (!isDuplicateQuery(candidate, usedQueries, { roundIndex })) {
|
|
355
394
|
fallbacks.push({
|
|
356
395
|
query: candidate,
|
|
@@ -438,7 +477,9 @@ async function evaluateResearchQuality(
|
|
|
438
477
|
|
|
439
478
|
function summarizeEngineAnswers(result) {
|
|
440
479
|
const summaries = {};
|
|
441
|
-
for (const engine of
|
|
480
|
+
for (const engine of Object.keys(result || {}).filter(
|
|
481
|
+
(key) => !key.startsWith("_"),
|
|
482
|
+
)) {
|
|
442
483
|
const value = result?.[engine];
|
|
443
484
|
if (!value) continue;
|
|
444
485
|
summaries[engine] = value.error
|
|
@@ -598,9 +639,10 @@ async function executeResearchAction(
|
|
|
598
639
|
engines: ["fetch"],
|
|
599
640
|
engineCount: 1,
|
|
600
641
|
perEngine: {},
|
|
601
|
-
sourceType:
|
|
642
|
+
sourceType: classifySourceType(
|
|
602
643
|
domain,
|
|
603
644
|
fetchResult.title || "",
|
|
645
|
+
fetchResult.finalUrl || normalizedUrl,
|
|
604
646
|
),
|
|
605
647
|
isOfficial: false,
|
|
606
648
|
smartScore: 0,
|
|
@@ -737,30 +779,6 @@ function getDomainFromUrl(rawUrl) {
|
|
|
737
779
|
}
|
|
738
780
|
}
|
|
739
781
|
|
|
740
|
-
function classifySourceTypeFromDomain(domain, title = "") {
|
|
741
|
-
const { matchesDomain, SOCIAL_HOSTS, COMMUNITY_HOSTS, NEWS_HOSTS } =
|
|
742
|
-
require("./sources.mjs");
|
|
743
|
-
const lowerTitle = title.toLowerCase();
|
|
744
|
-
|
|
745
|
-
if (domain === "github.com" || domain === "gitlab.com") return "repo";
|
|
746
|
-
if (matchesDomain(domain, SOCIAL_HOSTS)) return "social";
|
|
747
|
-
if (matchesDomain(domain, COMMUNITY_HOSTS)) return "community";
|
|
748
|
-
if (matchesDomain(domain, NEWS_HOSTS)) return "news";
|
|
749
|
-
if (
|
|
750
|
-
domain.startsWith("docs.") ||
|
|
751
|
-
domain.startsWith("developer.") ||
|
|
752
|
-
domain.startsWith("developers.") ||
|
|
753
|
-
domain.startsWith("api.") ||
|
|
754
|
-
lowerTitle.includes("documentation") ||
|
|
755
|
-
lowerTitle.includes("docs") ||
|
|
756
|
-
lowerTitle.includes("reference")
|
|
757
|
-
) {
|
|
758
|
-
return "official-docs";
|
|
759
|
-
}
|
|
760
|
-
if (domain.startsWith("blog.")) return "maintainer-blog";
|
|
761
|
-
return "website";
|
|
762
|
-
}
|
|
763
|
-
|
|
764
782
|
/**
|
|
765
783
|
* Normalize a GitHub root/tree URL into specific fetchable pages.
|
|
766
784
|
* Expands github.com/owner/repo into [README, CONTRIBUTING, CHANGELOG, key files].
|
|
@@ -855,11 +873,161 @@ export function queriesToActions(queries) {
|
|
|
855
873
|
.filter((a) => a.query);
|
|
856
874
|
}
|
|
857
875
|
|
|
876
|
+
function sourceKey(source) {
|
|
877
|
+
return (
|
|
878
|
+
normalizeUrl(
|
|
879
|
+
source?.finalUrl || source?.canonicalUrl || source?.url || "",
|
|
880
|
+
) ||
|
|
881
|
+
source?.id ||
|
|
882
|
+
""
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function buildEvidenceExtractionPrompt(
|
|
887
|
+
originalQuery,
|
|
888
|
+
questions,
|
|
889
|
+
fetchedSources,
|
|
890
|
+
alreadyExtracted = new Set(),
|
|
891
|
+
) {
|
|
892
|
+
const openQuestions = (questions || [])
|
|
893
|
+
.filter((q) => q.status !== "closed")
|
|
894
|
+
.slice(0, 12)
|
|
895
|
+
.map((q) => ({ id: q.id, question: q.question }));
|
|
896
|
+
const sourceSnippets = (fetchedSources || [])
|
|
897
|
+
.filter((source) => source?.content || source?.snippet)
|
|
898
|
+
.filter((source) => !alreadyExtracted.has(sourceKey(source)))
|
|
899
|
+
.slice(0, 6)
|
|
900
|
+
.map((source, index) => ({
|
|
901
|
+
id: source.id || `F${index + 1}`,
|
|
902
|
+
title: source.title || "",
|
|
903
|
+
url: source.finalUrl || source.url || source.canonicalUrl || "",
|
|
904
|
+
content: trimText(source.content || source.snippet || "", 5000),
|
|
905
|
+
}));
|
|
906
|
+
|
|
907
|
+
return [
|
|
908
|
+
"You are doing goal-based evidence extraction for an iterative research run.",
|
|
909
|
+
"For each source, extract only information that helps answer the open questions.",
|
|
910
|
+
"Use original wording/details where useful. Do not invent answers; leave questions open if evidence is insufficient.",
|
|
911
|
+
"If a source answers one or more tracked questions, identify those question IDs explicitly.",
|
|
912
|
+
"Also propose genuinely new sub-questions discovered from the evidence.",
|
|
913
|
+
"",
|
|
914
|
+
`Original research question: ${originalQuery}`,
|
|
915
|
+
`Open question ledger: ${JSON.stringify(openQuestions, null, 2)}`,
|
|
916
|
+
`Fetched sources: ${JSON.stringify(sourceSnippets, null, 2)}`,
|
|
917
|
+
"",
|
|
918
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
919
|
+
"BEGIN_JSON",
|
|
920
|
+
JSON.stringify(
|
|
921
|
+
{
|
|
922
|
+
extractions: [
|
|
923
|
+
{
|
|
924
|
+
sourceId: "S1",
|
|
925
|
+
url: "https://example.com/source",
|
|
926
|
+
rational: "why this source matters for the goal",
|
|
927
|
+
evidence:
|
|
928
|
+
"specific quoted/paraphrased evidence with numbers, dates, caveats",
|
|
929
|
+
summary: "concise contribution to the research question",
|
|
930
|
+
answers: [
|
|
931
|
+
{
|
|
932
|
+
id: "Q1",
|
|
933
|
+
evidence: "brief evidence that closes the question",
|
|
934
|
+
},
|
|
935
|
+
],
|
|
936
|
+
newQuestions: ["new sub-question raised by this source"],
|
|
937
|
+
},
|
|
938
|
+
],
|
|
939
|
+
},
|
|
940
|
+
null,
|
|
941
|
+
2,
|
|
942
|
+
),
|
|
943
|
+
"END_JSON",
|
|
944
|
+
].join("\n");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function normalizeEvidenceExtractions(payload, fetchedSources) {
|
|
948
|
+
const raw = Array.isArray(payload?.extractions) ? payload.extractions : [];
|
|
949
|
+
const byUrl = new Map();
|
|
950
|
+
const byId = new Map();
|
|
951
|
+
for (const source of fetchedSources || []) {
|
|
952
|
+
if (source?.id) byId.set(String(source.id), source);
|
|
953
|
+
const key = sourceKey(source);
|
|
954
|
+
if (key) byUrl.set(key, source);
|
|
955
|
+
}
|
|
956
|
+
return raw
|
|
957
|
+
.map((item) => {
|
|
958
|
+
const source =
|
|
959
|
+
byId.get(String(item?.sourceId || "")) ||
|
|
960
|
+
byUrl.get(normalizeUrl(item?.url || "") || "");
|
|
961
|
+
const sourceId = String(item?.sourceId || source?.id || "");
|
|
962
|
+
const url = normalizeUrl(
|
|
963
|
+
item?.url || source?.finalUrl || source?.url || "",
|
|
964
|
+
);
|
|
965
|
+
const answers = Array.isArray(item?.answers)
|
|
966
|
+
? item.answers
|
|
967
|
+
.map((answer) => ({
|
|
968
|
+
id: String(answer?.id || ""),
|
|
969
|
+
evidence: trimText(answer?.evidence || "", 500),
|
|
970
|
+
sourceIds: [sourceId].filter(Boolean),
|
|
971
|
+
}))
|
|
972
|
+
.filter((answer) => answer.id)
|
|
973
|
+
: [];
|
|
974
|
+
return {
|
|
975
|
+
sourceId,
|
|
976
|
+
url,
|
|
977
|
+
title: source?.title || item?.title || "",
|
|
978
|
+
rational: trimText(item?.rational || "", 700),
|
|
979
|
+
evidence: trimText(item?.evidence || "", 1600),
|
|
980
|
+
summary: trimText(item?.summary || "", 700),
|
|
981
|
+
answers,
|
|
982
|
+
newQuestions: uniqueStrings(item?.newQuestions || [], 6),
|
|
983
|
+
};
|
|
984
|
+
})
|
|
985
|
+
.filter(
|
|
986
|
+
(item) => item.sourceId || item.url || item.summary || item.evidence,
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export async function extractEvidenceFromSources({
|
|
991
|
+
query,
|
|
992
|
+
questions,
|
|
993
|
+
fetchedSources,
|
|
994
|
+
extractedSourceKeys,
|
|
995
|
+
}) {
|
|
996
|
+
const pending = (fetchedSources || []).filter(
|
|
997
|
+
(source) =>
|
|
998
|
+
(source?.content || source?.snippet) &&
|
|
999
|
+
!extractedSourceKeys.has(sourceKey(source)),
|
|
1000
|
+
);
|
|
1001
|
+
if (pending.length === 0) return { evidence: [], error: "" };
|
|
1002
|
+
try {
|
|
1003
|
+
const raw = await runGeminiPrompt(
|
|
1004
|
+
buildEvidenceExtractionPrompt(
|
|
1005
|
+
query,
|
|
1006
|
+
questions,
|
|
1007
|
+
pending,
|
|
1008
|
+
extractedSourceKeys,
|
|
1009
|
+
),
|
|
1010
|
+
{ timeoutMs: 120000 },
|
|
1011
|
+
);
|
|
1012
|
+
const parsed = parseGeminiJson(raw, { extractions: [] });
|
|
1013
|
+
const evidence = normalizeEvidenceExtractions(parsed, pending);
|
|
1014
|
+
for (const source of pending) {
|
|
1015
|
+
const key = sourceKey(source);
|
|
1016
|
+
if (key) extractedSourceKeys.add(key);
|
|
1017
|
+
}
|
|
1018
|
+
return { evidence, error: "" };
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
return { evidence: [], error: error.message || String(error) };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
858
1024
|
function buildLearningPrompt(
|
|
859
1025
|
originalQuery,
|
|
860
1026
|
roundQueries,
|
|
861
1027
|
searchSummaries,
|
|
862
1028
|
fetchedSources,
|
|
1029
|
+
questions = [],
|
|
1030
|
+
evidenceItems = [],
|
|
863
1031
|
) {
|
|
864
1032
|
const sourceSnippets = fetchedSources
|
|
865
1033
|
.filter((source) => source?.content || source?.snippet)
|
|
@@ -878,6 +1046,8 @@ function buildLearningPrompt(
|
|
|
878
1046
|
"",
|
|
879
1047
|
`Original research question: ${originalQuery}`,
|
|
880
1048
|
`Round queries: ${JSON.stringify(roundQueries, null, 2)}`,
|
|
1049
|
+
`Question ledger: ${JSON.stringify(questions, null, 2)}`,
|
|
1050
|
+
`Extracted source evidence: ${JSON.stringify(evidenceItems.slice(-12), null, 2)}`,
|
|
881
1051
|
`Engine summaries: ${JSON.stringify(searchSummaries, null, 2)}`,
|
|
882
1052
|
`Fetched source snippets: ${JSON.stringify(sourceSnippets, null, 2)}`,
|
|
883
1053
|
"",
|
|
@@ -886,6 +1056,14 @@ function buildLearningPrompt(
|
|
|
886
1056
|
JSON.stringify(
|
|
887
1057
|
{
|
|
888
1058
|
learnings: ["concise, information-dense learning"],
|
|
1059
|
+
answeredQuestions: [
|
|
1060
|
+
{
|
|
1061
|
+
id: "Q1",
|
|
1062
|
+
evidence: "brief evidence that closes this question",
|
|
1063
|
+
sourceIds: ["S1"],
|
|
1064
|
+
},
|
|
1065
|
+
],
|
|
1066
|
+
newQuestions: ["new sub-question discovered from the evidence"],
|
|
889
1067
|
followUpQueries: ["specific next search query"],
|
|
890
1068
|
gaps: ["important uncertainty or missing evidence"],
|
|
891
1069
|
},
|
|
@@ -896,7 +1074,13 @@ function buildLearningPrompt(
|
|
|
896
1074
|
].join("\n");
|
|
897
1075
|
}
|
|
898
1076
|
|
|
899
|
-
function buildFinalReportPrompt(
|
|
1077
|
+
export function buildFinalReportPrompt(
|
|
1078
|
+
originalQuery,
|
|
1079
|
+
rounds,
|
|
1080
|
+
sources,
|
|
1081
|
+
questions = [],
|
|
1082
|
+
evidenceItems = [],
|
|
1083
|
+
) {
|
|
900
1084
|
const learnings = rounds.flatMap((round) => round.learnings || []);
|
|
901
1085
|
const gaps = rounds.flatMap((round) => round.gaps || []);
|
|
902
1086
|
const sourceRegistry = sources.slice(0, 12).map((source) => ({
|
|
@@ -932,6 +1116,8 @@ function buildFinalReportPrompt(originalQuery, rounds, sources) {
|
|
|
932
1116
|
`Original research question: ${originalQuery}`,
|
|
933
1117
|
`Learnings: ${JSON.stringify(learnings, null, 2)}`,
|
|
934
1118
|
`Known gaps/caveats: ${JSON.stringify(gaps, null, 2)}`,
|
|
1119
|
+
`Question ledger: ${JSON.stringify(questions, null, 2)}`,
|
|
1120
|
+
`Goal-based extracted evidence: ${JSON.stringify(evidenceItems.slice(-20), null, 2)}`,
|
|
935
1121
|
`Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
|
|
936
1122
|
"",
|
|
937
1123
|
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
@@ -961,6 +1147,81 @@ function buildFinalReportPrompt(originalQuery, rounds, sources) {
|
|
|
961
1147
|
].join("\n");
|
|
962
1148
|
}
|
|
963
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* Build a synthesis prompt that derives the final report directly from
|
|
1152
|
+
* previously extracted evidence (no per-round learnings required). This is
|
|
1153
|
+
* used as a fallback when the regular final-report path returns no
|
|
1154
|
+
* structured learnings (for example when Gemini's input field rejected the
|
|
1155
|
+
* per-round learning prompt but the goal-based extraction step succeeded).
|
|
1156
|
+
*/
|
|
1157
|
+
export function buildSynthesisFromEvidencePrompt(
|
|
1158
|
+
originalQuery,
|
|
1159
|
+
sources = [],
|
|
1160
|
+
questions = [],
|
|
1161
|
+
evidenceItems = [],
|
|
1162
|
+
) {
|
|
1163
|
+
const sourceRegistry = sources.slice(0, 12).map((source) => ({
|
|
1164
|
+
id: source.id,
|
|
1165
|
+
title: source.title,
|
|
1166
|
+
domain: source.domain,
|
|
1167
|
+
url: source.canonicalUrl,
|
|
1168
|
+
type: source.sourceType,
|
|
1169
|
+
engines: source.engines,
|
|
1170
|
+
}));
|
|
1171
|
+
const evidenceSlice = evidenceItems.slice(-20);
|
|
1172
|
+
const answerableQuestionIds = new Set();
|
|
1173
|
+
for (const item of evidenceSlice) {
|
|
1174
|
+
for (const ans of item.answers || []) {
|
|
1175
|
+
if (ans?.id) answerableQuestionIds.add(ans.id);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const openQuestionSummary = (questions || [])
|
|
1179
|
+
.filter((q) => q.status !== "closed")
|
|
1180
|
+
.map((q) => ({ id: q.id, question: q.question }));
|
|
1181
|
+
|
|
1182
|
+
return [
|
|
1183
|
+
"You are writing the final research report from goal-based extracted evidence.",
|
|
1184
|
+
"Per-round learnings were not produced, but the per-source evidence extraction step succeeded.",
|
|
1185
|
+
"Synthesize a thorough markdown report using ONLY the evidence below. Every substantive claim MUST be backed by an [S1] citation.",
|
|
1186
|
+
"",
|
|
1187
|
+
"Report structure:",
|
|
1188
|
+
"1. ## Summary — A 2-4 sentence executive summary of findings",
|
|
1189
|
+
"2. ## Key Findings — The main findings, organized by theme or question, each with inline citations",
|
|
1190
|
+
"3. ## Limitations & Caveats — Important qualifiers, gaps, or uncertainties",
|
|
1191
|
+
"",
|
|
1192
|
+
`Original research question: ${originalQuery}`,
|
|
1193
|
+
`Per-source extracted evidence: ${JSON.stringify(evidenceSlice, null, 2)}`,
|
|
1194
|
+
`Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
|
|
1195
|
+
`Questions already answered by the evidence: ${JSON.stringify(Array.from(answerableQuestionIds))}`,
|
|
1196
|
+
`Questions still open after this evidence: ${JSON.stringify(openQuestionSummary)}`,
|
|
1197
|
+
"",
|
|
1198
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
1199
|
+
"BEGIN_JSON",
|
|
1200
|
+
JSON.stringify(
|
|
1201
|
+
{
|
|
1202
|
+
answer: "markdown report with sections and inline [S1] citations",
|
|
1203
|
+
agreement: {
|
|
1204
|
+
level: "high|medium|low|mixed|conflicting",
|
|
1205
|
+
summary: "one-sentence confidence summary",
|
|
1206
|
+
},
|
|
1207
|
+
differences: ["notable disagreement or conflict between sources"],
|
|
1208
|
+
caveats: ["important caveat or qualification"],
|
|
1209
|
+
claims: [
|
|
1210
|
+
{
|
|
1211
|
+
claim: "specific factual statement supported by the evidence",
|
|
1212
|
+
support: "strong|moderate|weak|conflicting",
|
|
1213
|
+
sourceIds: ["S1", "S2"],
|
|
1214
|
+
},
|
|
1215
|
+
],
|
|
1216
|
+
recommendedSources: ["S1", "S2"],
|
|
1217
|
+
},
|
|
1218
|
+
null,
|
|
1219
|
+
2,
|
|
1220
|
+
),
|
|
1221
|
+
"END_JSON",
|
|
1222
|
+
].join("\n");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
964
1225
|
async function runFastAllSearch(query, { locale = null, short = true } = {}) {
|
|
965
1226
|
const args = [SEARCH_BIN, "all", "--inline", "--stdin", "--fast"];
|
|
966
1227
|
if (!short) args.push("--full");
|
|
@@ -1046,7 +1307,9 @@ function shouldForwardChildStderr(line) {
|
|
|
1046
1307
|
return (
|
|
1047
1308
|
/^PROGRESS:/.test(line) ||
|
|
1048
1309
|
/^\[greedysearch\]/.test(line) ||
|
|
1049
|
-
/^\[(bing|perplexity|google|gemini)\]/.test(
|
|
1310
|
+
/^\[(bing|perplexity|google|gemini|chatgpt|logically|semantic-scholar)\]/.test(
|
|
1311
|
+
line,
|
|
1312
|
+
) ||
|
|
1050
1313
|
/^GreedySearch Chrome/.test(line) ||
|
|
1051
1314
|
/^Launching GreedySearch Chrome/.test(line) ||
|
|
1052
1315
|
/^Headless mode/.test(line) ||
|
|
@@ -1141,6 +1404,811 @@ export function auditCitations(answer, sources) {
|
|
|
1141
1404
|
};
|
|
1142
1405
|
}
|
|
1143
1406
|
|
|
1407
|
+
/**
|
|
1408
|
+
* Check reachability of cited source URLs via HEAD requests.
|
|
1409
|
+
* Returns { reachable, dead, skipped } with per-URL status.
|
|
1410
|
+
*/
|
|
1411
|
+
export async function checkCitationUrls(
|
|
1412
|
+
sources,
|
|
1413
|
+
{ timeoutMs = 6000, concurrency = 4 } = {},
|
|
1414
|
+
) {
|
|
1415
|
+
const safeConcurrency = Math.max(1, Math.floor(concurrency || 1));
|
|
1416
|
+
const citedSources = (sources || []).filter(
|
|
1417
|
+
(s) => s?.id && (s?.canonicalUrl || s?.finalUrl || s?.url),
|
|
1418
|
+
);
|
|
1419
|
+
if (citedSources.length === 0) {
|
|
1420
|
+
return { reachable: [], dead: [], skipped: [], ok: true };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const reachable = [];
|
|
1424
|
+
const dead = [];
|
|
1425
|
+
const skipped = [];
|
|
1426
|
+
|
|
1427
|
+
// Process in batches to avoid overwhelming
|
|
1428
|
+
for (let i = 0; i < citedSources.length; i += safeConcurrency) {
|
|
1429
|
+
const batch = citedSources.slice(i, i + safeConcurrency);
|
|
1430
|
+
const results = await Promise.allSettled(
|
|
1431
|
+
batch.map(async (source) => {
|
|
1432
|
+
const url =
|
|
1433
|
+
source.fetch?.finalUrl ||
|
|
1434
|
+
source.canonicalUrl ||
|
|
1435
|
+
source.finalUrl ||
|
|
1436
|
+
source.url;
|
|
1437
|
+
if (!url) return { id: source.id, url: "", status: "skipped" };
|
|
1438
|
+
|
|
1439
|
+
// Skip non-HTTP URLs and known-unreachable patterns
|
|
1440
|
+
try {
|
|
1441
|
+
const parsed = new URL(url);
|
|
1442
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1443
|
+
return { id: source.id, url, status: "skipped" };
|
|
1444
|
+
}
|
|
1445
|
+
} catch {
|
|
1446
|
+
return { id: source.id, url, status: "skipped" };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
try {
|
|
1450
|
+
const controller = new AbortController();
|
|
1451
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1452
|
+
try {
|
|
1453
|
+
const response = await fetch(url, {
|
|
1454
|
+
method: "HEAD",
|
|
1455
|
+
redirect: "follow",
|
|
1456
|
+
signal: controller.signal,
|
|
1457
|
+
headers: {
|
|
1458
|
+
"User-Agent":
|
|
1459
|
+
"Mozilla/5.0 (compatible; GreedySearch/2.0; +https://github.com/apmantza/greedysearch-pi)",
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
clearTimeout(timer);
|
|
1463
|
+
const ok = response.status >= 200 && response.status < 400;
|
|
1464
|
+
return {
|
|
1465
|
+
id: source.id,
|
|
1466
|
+
url,
|
|
1467
|
+
status: ok ? "reachable" : "dead",
|
|
1468
|
+
httpStatus: response.status,
|
|
1469
|
+
};
|
|
1470
|
+
} catch (fetchError) {
|
|
1471
|
+
clearTimeout(timer);
|
|
1472
|
+
return {
|
|
1473
|
+
id: source.id,
|
|
1474
|
+
url,
|
|
1475
|
+
status: "dead",
|
|
1476
|
+
error:
|
|
1477
|
+
fetchError.name === "AbortError"
|
|
1478
|
+
? "timeout"
|
|
1479
|
+
: fetchError.message,
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
return {
|
|
1484
|
+
id: source.id,
|
|
1485
|
+
url,
|
|
1486
|
+
status: "dead",
|
|
1487
|
+
error: error.message,
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
}),
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
for (const result of results) {
|
|
1494
|
+
const value =
|
|
1495
|
+
result.status === "fulfilled"
|
|
1496
|
+
? result.value
|
|
1497
|
+
: {
|
|
1498
|
+
id: "?",
|
|
1499
|
+
url: "",
|
|
1500
|
+
status: "dead",
|
|
1501
|
+
error: result.reason?.message || "unknown",
|
|
1502
|
+
};
|
|
1503
|
+
if (value.status === "reachable") reachable.push(value);
|
|
1504
|
+
else if (value.status === "dead") dead.push(value);
|
|
1505
|
+
else skipped.push(value);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return {
|
|
1510
|
+
reachable,
|
|
1511
|
+
dead,
|
|
1512
|
+
skipped,
|
|
1513
|
+
ok: dead.length === 0,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Shared orchestration: run citation URL check with logging.
|
|
1519
|
+
* Used by both runResearchMode() and runSimpleResearchMode() to avoid
|
|
1520
|
+
* duplicating the try/catch/logging block.
|
|
1521
|
+
*/
|
|
1522
|
+
export async function runCitationUrlCheck(combinedSources) {
|
|
1523
|
+
process.stderr.write("PROGRESS:research:check-urls\n");
|
|
1524
|
+
try {
|
|
1525
|
+
const citationUrls = await checkCitationUrls(combinedSources, {
|
|
1526
|
+
timeoutMs: 6000,
|
|
1527
|
+
concurrency: 4,
|
|
1528
|
+
});
|
|
1529
|
+
if (!citationUrls.ok) {
|
|
1530
|
+
process.stderr.write(
|
|
1531
|
+
`[greedysearch] ${citationUrls.dead.length} dead citation URL(s) detected\n`,
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
return citationUrls;
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
process.stderr.write(
|
|
1537
|
+
`[greedysearch] URL reachability check failed: ${error.message}\n`,
|
|
1538
|
+
);
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
export function computeResearchFloor({
|
|
1544
|
+
sources = [],
|
|
1545
|
+
fetchedSources = [],
|
|
1546
|
+
synthesis = {},
|
|
1547
|
+
citationAudit = null,
|
|
1548
|
+
gaps = [],
|
|
1549
|
+
questions = [],
|
|
1550
|
+
rounds = [],
|
|
1551
|
+
qualityScore = 0,
|
|
1552
|
+
qualityThreshold = 8.5,
|
|
1553
|
+
maxSources = 8,
|
|
1554
|
+
requireCitations = true,
|
|
1555
|
+
requireQuestions = true,
|
|
1556
|
+
} = {}) {
|
|
1557
|
+
const fetchedOk = fetchedSources.filter(
|
|
1558
|
+
(source) =>
|
|
1559
|
+
source?.fetch?.ok ||
|
|
1560
|
+
(source?.contentChars || 0) > 100 ||
|
|
1561
|
+
String(source?.content || "").length > 100,
|
|
1562
|
+
);
|
|
1563
|
+
const primarySources = sources.filter((source) =>
|
|
1564
|
+
["official-docs", "repo", "maintainer-blog", "academic"].includes(
|
|
1565
|
+
String(source?.sourceType || ""),
|
|
1566
|
+
),
|
|
1567
|
+
);
|
|
1568
|
+
const claims = Array.isArray(synthesis?.claims) ? synthesis.claims : [];
|
|
1569
|
+
const citedCount = citationAudit ? citationAudit.cited?.length || 0 : 0;
|
|
1570
|
+
const questionStats = questionProgress(questions);
|
|
1571
|
+
// Follow-up questions discovered during a run are useful handoff gaps, not a
|
|
1572
|
+
// reason to fail a short research run forever. The deterministic floor only
|
|
1573
|
+
// requires the original/root questions to close; newly-created questions stay
|
|
1574
|
+
// visible in STATUS.md and `gaps` for deeper follow-up rounds.
|
|
1575
|
+
const requiredQuestions = (questions || []).filter(
|
|
1576
|
+
(q) => !q.createdRound || q.reason === "Original research question",
|
|
1577
|
+
);
|
|
1578
|
+
const requiredQuestionStats = questionProgress(requiredQuestions);
|
|
1579
|
+
// Scale the minimum fetched sources by the number of rounds. The
|
|
1580
|
+
// simple research path runs 1 round with fewer sources, so requiring
|
|
1581
|
+
// 2-4 sources would be too strict. Iterative research (2+ rounds)
|
|
1582
|
+
// gets the full minFetched requirement.
|
|
1583
|
+
const roundCount = (rounds || []).length;
|
|
1584
|
+
const baseMin = Math.min(4, Math.max(2, Number(maxSources) || 8));
|
|
1585
|
+
const minFetched = roundCount <= 1 ? Math.min(2, baseMin) : baseMin;
|
|
1586
|
+
const checks = {
|
|
1587
|
+
roundsRun: rounds.length >= 1,
|
|
1588
|
+
fetchedSources: fetchedOk.length >= minFetched,
|
|
1589
|
+
primarySources: primarySources.length >= 1,
|
|
1590
|
+
qualityScore: qualityScore >= Math.min(qualityThreshold, 8),
|
|
1591
|
+
claimsExtracted: !requireCitations || claims.length > 0,
|
|
1592
|
+
citationsPresent: !requireCitations || citedCount > 0,
|
|
1593
|
+
citationsValid: !requireCitations || citationAudit?.ok === true,
|
|
1594
|
+
unfetchedCitations:
|
|
1595
|
+
!requireCitations || (citationAudit?.unfetched || []).length === 0,
|
|
1596
|
+
requiredQuestionsClosed:
|
|
1597
|
+
!requireQuestions || requiredQuestionStats.open === 0,
|
|
1598
|
+
};
|
|
1599
|
+
return {
|
|
1600
|
+
floorMet: Object.values(checks).every(Boolean),
|
|
1601
|
+
checks,
|
|
1602
|
+
metrics: {
|
|
1603
|
+
fetchedOk: fetchedOk.length,
|
|
1604
|
+
primarySources: primarySources.length,
|
|
1605
|
+
claims: claims.length,
|
|
1606
|
+
cited: citedCount,
|
|
1607
|
+
gaps: gaps.length,
|
|
1608
|
+
openQuestions: questionStats.open,
|
|
1609
|
+
closedQuestions: questionStats.closed,
|
|
1610
|
+
totalQuestions: questionStats.total,
|
|
1611
|
+
openRequiredQuestions: requiredQuestionStats.open,
|
|
1612
|
+
closedRequiredQuestions: requiredQuestionStats.closed,
|
|
1613
|
+
totalRequiredQuestions: requiredQuestionStats.total,
|
|
1614
|
+
qualityScore,
|
|
1615
|
+
minFetched,
|
|
1616
|
+
},
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function annotateFetchedSourcesWithIds(fetchedSources, sources) {
|
|
1621
|
+
const byUrl = new Map();
|
|
1622
|
+
for (const source of sources || []) {
|
|
1623
|
+
const key = normalizeUrl(
|
|
1624
|
+
source?.canonicalUrl || source?.finalUrl || source?.url,
|
|
1625
|
+
);
|
|
1626
|
+
if (key && source?.id) byUrl.set(key, source.id);
|
|
1627
|
+
}
|
|
1628
|
+
return (fetchedSources || []).map((source, index) => {
|
|
1629
|
+
const key = normalizeUrl(
|
|
1630
|
+
source?.finalUrl || source?.canonicalUrl || source?.url,
|
|
1631
|
+
);
|
|
1632
|
+
return {
|
|
1633
|
+
...source,
|
|
1634
|
+
id: source?.id || byUrl.get(key) || `F${index + 1}`,
|
|
1635
|
+
};
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export function createQuestionLedger(query) {
|
|
1640
|
+
return [
|
|
1641
|
+
{
|
|
1642
|
+
id: "Q1",
|
|
1643
|
+
question: trimText(sanitizeResearchQuery(query), 320),
|
|
1644
|
+
status: "open",
|
|
1645
|
+
reason: "Original research question",
|
|
1646
|
+
evidence: [],
|
|
1647
|
+
sourceIds: [],
|
|
1648
|
+
},
|
|
1649
|
+
];
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function nextQuestionId(questions) {
|
|
1653
|
+
let max = 0;
|
|
1654
|
+
for (const q of questions || []) {
|
|
1655
|
+
const n = Number.parseInt(String(q.id || "").replace(/^Q/i, ""), 10);
|
|
1656
|
+
if (Number.isFinite(n)) max = Math.max(max, n);
|
|
1657
|
+
}
|
|
1658
|
+
return `Q${max + 1}`;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function findSimilarQuestion(questions, question) {
|
|
1662
|
+
const normalized = sanitizeResearchQuery(question).toLowerCase();
|
|
1663
|
+
return (questions || []).find(
|
|
1664
|
+
(q) =>
|
|
1665
|
+
q.question?.toLowerCase() === normalized ||
|
|
1666
|
+
jaccardSimilarity(q.question || "", normalized) >= 0.82,
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function addQuestion(questions, question, { reason = "", round = null } = {}) {
|
|
1671
|
+
const clean = trimText(sanitizeResearchQuery(question), 320);
|
|
1672
|
+
if (!clean) return null;
|
|
1673
|
+
const existing = findSimilarQuestion(questions, clean);
|
|
1674
|
+
if (existing) return existing;
|
|
1675
|
+
const item = {
|
|
1676
|
+
id: nextQuestionId(questions),
|
|
1677
|
+
question: clean,
|
|
1678
|
+
status: "open",
|
|
1679
|
+
reason: trimText(reason, 240),
|
|
1680
|
+
createdRound: round,
|
|
1681
|
+
evidence: [],
|
|
1682
|
+
sourceIds: [],
|
|
1683
|
+
};
|
|
1684
|
+
questions.push(item);
|
|
1685
|
+
return item;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function closeQuestion(
|
|
1689
|
+
questions,
|
|
1690
|
+
idOrQuestion,
|
|
1691
|
+
{ evidence = "", sourceIds = [], round = null } = {},
|
|
1692
|
+
) {
|
|
1693
|
+
const target =
|
|
1694
|
+
questions.find((q) => q.id === idOrQuestion) ||
|
|
1695
|
+
findSimilarQuestion(questions, idOrQuestion);
|
|
1696
|
+
if (!target) return null;
|
|
1697
|
+
target.status = "closed";
|
|
1698
|
+
target.closedRound = target.closedRound || round;
|
|
1699
|
+
if (evidence)
|
|
1700
|
+
target.evidence = uniqueStrings([...(target.evidence || []), evidence], 4);
|
|
1701
|
+
if (Array.isArray(sourceIds)) {
|
|
1702
|
+
target.sourceIds = uniqueStrings(
|
|
1703
|
+
[...(target.sourceIds || []), ...sourceIds],
|
|
1704
|
+
8,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
return target;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function questionProgress(questions) {
|
|
1711
|
+
const total = questions.length;
|
|
1712
|
+
const closed = questions.filter((q) => q.status === "closed").length;
|
|
1713
|
+
return { total, closed, open: Math.max(0, total - closed) };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
export function updateQuestionLedger(
|
|
1717
|
+
questions,
|
|
1718
|
+
{ roundNumber, actions = [], learningPayload = {} } = {},
|
|
1719
|
+
) {
|
|
1720
|
+
for (const run of actions) {
|
|
1721
|
+
const action = run?.action || run;
|
|
1722
|
+
const goal =
|
|
1723
|
+
action?.researchGoal && action.researchGoal !== "Original user query"
|
|
1724
|
+
? action.researchGoal
|
|
1725
|
+
: action?.query || action?.url || "";
|
|
1726
|
+
if (goal) {
|
|
1727
|
+
addQuestion(questions, goal, {
|
|
1728
|
+
reason: "Planned research action",
|
|
1729
|
+
round: roundNumber,
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Cap the open-question ledger growth. Discovered gap/follow-up questions
|
|
1735
|
+
// are useful handoffs but Gemini tends to emit one per evidence slot, which
|
|
1736
|
+
// blows up the ledger and inflates the `requiredQuestionsClosed` floor
|
|
1737
|
+
// check. Keep at most MAX_OPEN_FOLLOWUPS of them across the whole run;
|
|
1738
|
+
// older ones are auto-resolved as "covered by later evidence" so they
|
|
1739
|
+
// don't block the floor forever.
|
|
1740
|
+
const MAX_OPEN_FOLLOWUPS = 5;
|
|
1741
|
+
const followupOpen = questions.filter(
|
|
1742
|
+
(q) => q.status === "open" && q.reason === "Discovered gap/follow-up",
|
|
1743
|
+
);
|
|
1744
|
+
if (followupOpen.length > MAX_OPEN_FOLLOWUPS) {
|
|
1745
|
+
const overflow = followupOpen
|
|
1746
|
+
.sort((a, b) => (a.createdRound || 0) - (b.createdRound || 0))
|
|
1747
|
+
.slice(0, followupOpen.length - MAX_OPEN_FOLLOWUPS);
|
|
1748
|
+
for (const q of overflow) {
|
|
1749
|
+
q.status = "resolved";
|
|
1750
|
+
q.closedRound = roundNumber;
|
|
1751
|
+
q.evidence = uniqueStrings(
|
|
1752
|
+
[...(q.evidence || []), "Auto-resolved to cap open-question ledger"],
|
|
1753
|
+
4,
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const answered = Array.isArray(learningPayload.answeredQuestions)
|
|
1759
|
+
? learningPayload.answeredQuestions
|
|
1760
|
+
: [];
|
|
1761
|
+
for (const item of answered) {
|
|
1762
|
+
if (typeof item === "string") {
|
|
1763
|
+
closeQuestion(questions, item, { round: roundNumber });
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
const id = item?.id || item?.question;
|
|
1767
|
+
if (!id && item?.question) {
|
|
1768
|
+
const added = addQuestion(questions, item.question, {
|
|
1769
|
+
reason: "Answered during learning extraction",
|
|
1770
|
+
round: roundNumber,
|
|
1771
|
+
});
|
|
1772
|
+
if (added) closeQuestion(questions, added.id, { round: roundNumber });
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
closeQuestion(questions, id, {
|
|
1776
|
+
evidence: item?.evidence || item?.answer || "",
|
|
1777
|
+
sourceIds: Array.isArray(item?.sourceIds) ? item.sourceIds : [],
|
|
1778
|
+
round: roundNumber,
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Keep STATUS.md as a true question ledger, not a dump of every search query
|
|
1783
|
+
// or caveat. Follow-up queries and raw gaps stay in their own fields; only
|
|
1784
|
+
// explicit newQuestions become open ledger items.
|
|
1785
|
+
const newQuestions = Array.isArray(learningPayload.newQuestions)
|
|
1786
|
+
? learningPayload.newQuestions
|
|
1787
|
+
: [];
|
|
1788
|
+
for (const question of newQuestions) {
|
|
1789
|
+
addQuestion(questions, question, {
|
|
1790
|
+
reason: "Discovered gap/follow-up",
|
|
1791
|
+
round: roundNumber,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
return questions;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Pick direct-fetch targets from known academic source domains (arXiv,
|
|
1800
|
+
* semanticscholar.org, DOI redirect). Returns the canonical URL plus a
|
|
1801
|
+
* short label for the researchGoal. Filters out anything already fetched.
|
|
1802
|
+
*/
|
|
1803
|
+
function pickAcademicFetchTargets(combinedSources, usedUrls) {
|
|
1804
|
+
if (!Array.isArray(combinedSources) || combinedSources.length === 0)
|
|
1805
|
+
return [];
|
|
1806
|
+
const ACADEMIC_HOSTS = ["arxiv.org", "semanticscholar.org", "doi.org"];
|
|
1807
|
+
const seen = new Set();
|
|
1808
|
+
const targets = [];
|
|
1809
|
+
for (const source of combinedSources) {
|
|
1810
|
+
const url = source?.canonicalUrl || source?.finalUrl || source?.url || "";
|
|
1811
|
+
if (!url) continue;
|
|
1812
|
+
let domain = "";
|
|
1813
|
+
try {
|
|
1814
|
+
domain = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
1815
|
+
} catch {
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (!ACADEMIC_HOSTS.some((h) => domain === h || domain.endsWith(`.${h}`))) {
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
if (usedUrls.has(url) || seen.has(url)) continue;
|
|
1822
|
+
seen.add(url);
|
|
1823
|
+
// Prefer the HTML/abs page over PDF for direct fetch — the source
|
|
1824
|
+
// fetcher handles both, but the HTML page gives the synthesizer
|
|
1825
|
+
// readable text + abstract immediately.
|
|
1826
|
+
const htmlUrl = url.includes("/pdf/")
|
|
1827
|
+
? url.replace(/\/pdf\//, "/html/").replace(/\.pdf$/i, "")
|
|
1828
|
+
: url;
|
|
1829
|
+
targets.push({
|
|
1830
|
+
url: htmlUrl,
|
|
1831
|
+
label: source?.title || source?.id || domain,
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
return targets.slice(0, 2);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
export function reconcileQuestionsFromSynthesis(
|
|
1838
|
+
questions,
|
|
1839
|
+
synthesis,
|
|
1840
|
+
citationAudit,
|
|
1841
|
+
) {
|
|
1842
|
+
if (!synthesis?.answer || citationAudit?.ok !== true) return questions;
|
|
1843
|
+
const claims = Array.isArray(synthesis.claims) ? synthesis.claims : [];
|
|
1844
|
+
const citedIds = Array.isArray(citationAudit.cited)
|
|
1845
|
+
? citationAudit.cited
|
|
1846
|
+
: [];
|
|
1847
|
+
if (claims.length === 0 || citedIds.length === 0) return questions;
|
|
1848
|
+
|
|
1849
|
+
for (const question of questions) {
|
|
1850
|
+
if (question.status === "closed") continue;
|
|
1851
|
+
let bestClaim = null;
|
|
1852
|
+
let bestScore = 0;
|
|
1853
|
+
for (const claim of claims) {
|
|
1854
|
+
const score = jaccardSimilarity(
|
|
1855
|
+
question.question || "",
|
|
1856
|
+
claim.claim || "",
|
|
1857
|
+
);
|
|
1858
|
+
if (score > bestScore) {
|
|
1859
|
+
bestScore = score;
|
|
1860
|
+
bestClaim = claim;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (question.id === "Q1" || bestScore >= 0.18) {
|
|
1864
|
+
closeQuestion(questions, question.id, {
|
|
1865
|
+
evidence: bestClaim?.claim || "Answered in final cited synthesis",
|
|
1866
|
+
sourceIds: Array.isArray(bestClaim?.sourceIds)
|
|
1867
|
+
? bestClaim.sourceIds
|
|
1868
|
+
: citedIds.slice(0, 4),
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return questions;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function renderQuestionStatus(questions) {
|
|
1876
|
+
if (!questions.length) return "No tracked questions.";
|
|
1877
|
+
return questions
|
|
1878
|
+
.map((q) => {
|
|
1879
|
+
const ids = q.sourceIds?.length ? ` (${q.sourceIds.join(", ")})` : "";
|
|
1880
|
+
return `- [${q.status === "closed" ? "x" : " "}] ${q.id}: ${q.question}${ids}`;
|
|
1881
|
+
})
|
|
1882
|
+
.join("\n");
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function markdownList(items, fallback = "None recorded.") {
|
|
1886
|
+
const unique = uniqueStrings(items);
|
|
1887
|
+
return unique.length
|
|
1888
|
+
? unique.map((item) => `- ${item}`).join("\n")
|
|
1889
|
+
: fallback;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* Write a human-readable provenance sidecar next to the research bundle.
|
|
1894
|
+
* Records date, rounds, sources, verification status, and floor results.
|
|
1895
|
+
*/
|
|
1896
|
+
export function writeProvenanceSidecar(
|
|
1897
|
+
dir,
|
|
1898
|
+
{
|
|
1899
|
+
query,
|
|
1900
|
+
rounds,
|
|
1901
|
+
sources,
|
|
1902
|
+
fetchedSources,
|
|
1903
|
+
citationAudit,
|
|
1904
|
+
citationUrls,
|
|
1905
|
+
floor,
|
|
1906
|
+
manifest,
|
|
1907
|
+
},
|
|
1908
|
+
) {
|
|
1909
|
+
const fetchedOk = (fetchedSources || []).filter(
|
|
1910
|
+
(s) => s?.contentChars > 100 || s?.fetch?.ok,
|
|
1911
|
+
);
|
|
1912
|
+
const primarySources = (sources || []).filter((s) =>
|
|
1913
|
+
["official-docs", "repo", "maintainer-blog", "academic"].includes(
|
|
1914
|
+
String(s?.sourceType || ""),
|
|
1915
|
+
),
|
|
1916
|
+
);
|
|
1917
|
+
const citedIds = new Set(citationAudit?.cited || []);
|
|
1918
|
+
const citedSources = (sources || []).filter((s) => citedIds.has(s?.id));
|
|
1919
|
+
|
|
1920
|
+
const lines = [
|
|
1921
|
+
`# Provenance: ${query}`,
|
|
1922
|
+
"",
|
|
1923
|
+
`- **Date:** ${manifest?.startedAt || new Date().toISOString()}`,
|
|
1924
|
+
`- **Duration:** ${manifest?.durationMs ? `${(manifest.durationMs / 1000).toFixed(1)}s` : "unknown"}`,
|
|
1925
|
+
`- **Mode:** ${manifest?.terminationReason === "simple_single_pass" ? "simple (single-pass)" : "iterative"}`,
|
|
1926
|
+
`- **Rounds:** ${manifest?.rounds || rounds?.length || 1}`,
|
|
1927
|
+
"",
|
|
1928
|
+
"## Sources",
|
|
1929
|
+
"",
|
|
1930
|
+
`- **Consulted:** ${sources?.length || 0}`,
|
|
1931
|
+
`- **Fetched successfully:** ${fetchedOk.length}`,
|
|
1932
|
+
`- **Primary sources:** ${primarySources.length}`,
|
|
1933
|
+
`- **Cited in report:** ${citedSources.length}`,
|
|
1934
|
+
"",
|
|
1935
|
+
];
|
|
1936
|
+
|
|
1937
|
+
// Cited source details
|
|
1938
|
+
if (citedSources.length > 0) {
|
|
1939
|
+
lines.push("### Cited sources", "");
|
|
1940
|
+
for (const source of citedSources) {
|
|
1941
|
+
const url = source.canonicalUrl || source.finalUrl || source.url || "";
|
|
1942
|
+
const fetched = source.fetch?.ok ? "✓" : "✗";
|
|
1943
|
+
lines.push(
|
|
1944
|
+
`- **${source.id}:** [${source.title || url}](${url}) (${source.sourceType || "unknown"}, fetched: ${fetched})`,
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
lines.push("");
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// URL reachability
|
|
1951
|
+
if (
|
|
1952
|
+
citationUrls &&
|
|
1953
|
+
(citationUrls.reachable.length > 0 || citationUrls.dead.length > 0)
|
|
1954
|
+
) {
|
|
1955
|
+
lines.push("## URL reachability", "");
|
|
1956
|
+
if (citationUrls.dead.length > 0) {
|
|
1957
|
+
lines.push("");
|
|
1958
|
+
lines.push("**Dead links:**");
|
|
1959
|
+
for (const d of citationUrls.dead) {
|
|
1960
|
+
lines.push(
|
|
1961
|
+
`- ${d.id}: ${d.url} (${d.httpStatus || d.error || "unknown"})`,
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
if (citationUrls.reachable.length > 0) {
|
|
1966
|
+
lines.push("");
|
|
1967
|
+
lines.push(
|
|
1968
|
+
`**Reachable:** ${citationUrls.reachable.length}/${citationUrls.reachable.length + citationUrls.dead.length}`,
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
lines.push("");
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Verification status
|
|
1975
|
+
const verificationStatus = !citationAudit
|
|
1976
|
+
? "NOT CHECKED"
|
|
1977
|
+
: citationAudit.ok && (citationUrls?.ok ?? true)
|
|
1978
|
+
? "PASS"
|
|
1979
|
+
: citationAudit.ok === false
|
|
1980
|
+
? "FAIL (missing citations)"
|
|
1981
|
+
: "FAIL (dead links)";
|
|
1982
|
+
|
|
1983
|
+
lines.push(
|
|
1984
|
+
"## Verification",
|
|
1985
|
+
"",
|
|
1986
|
+
`- **Citations:** ${citationAudit?.ok ? "PASS" : `FAIL — missing: ${(citationAudit?.missing || []).join(", ")}`}`,
|
|
1987
|
+
`- **URL reachability:** ${citationUrls ? (citationUrls.ok ? "PASS" : `FAIL — ${citationUrls.dead.length} dead`) : "SKIPPED"}`,
|
|
1988
|
+
`- **Floor:** ${floor?.floorMet ? "PASS" : "PARTIAL"}`,
|
|
1989
|
+
`- **Overall:** ${verificationStatus}`,
|
|
1990
|
+
"",
|
|
1991
|
+
);
|
|
1992
|
+
|
|
1993
|
+
// Floor checks
|
|
1994
|
+
if (floor?.checks) {
|
|
1995
|
+
lines.push("## Floor checks", "");
|
|
1996
|
+
for (const [name, ok] of Object.entries(floor.checks)) {
|
|
1997
|
+
lines.push(`- [${ok ? "x" : " "}] ${name}`);
|
|
1998
|
+
}
|
|
1999
|
+
lines.push("");
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
writeFileSync(join(dir, "provenance.md"), lines.join("\n"), "utf8");
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
export async function writeResearchBundle({
|
|
2006
|
+
query,
|
|
2007
|
+
rounds,
|
|
2008
|
+
sources,
|
|
2009
|
+
fetchedSources,
|
|
2010
|
+
evidenceItems = [],
|
|
2011
|
+
synthesis,
|
|
2012
|
+
citationAudit,
|
|
2013
|
+
floor,
|
|
2014
|
+
manifest,
|
|
2015
|
+
allGaps = [],
|
|
2016
|
+
questions = [],
|
|
2017
|
+
citationUrls = null,
|
|
2018
|
+
outDir = null,
|
|
2019
|
+
}) {
|
|
2020
|
+
const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 19);
|
|
2021
|
+
const dir =
|
|
2022
|
+
outDir ||
|
|
2023
|
+
join(
|
|
2024
|
+
DEFAULT_RESEARCH_BUNDLE_ROOT,
|
|
2025
|
+
`${stamp}_${slugifyResearchName(query)}`,
|
|
2026
|
+
);
|
|
2027
|
+
const reportsDir = join(dir, "reports");
|
|
2028
|
+
const sourcesDir = join(dir, "sources");
|
|
2029
|
+
const dataDir = join(dir, "data");
|
|
2030
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
2031
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
2032
|
+
mkdirSync(dataDir, { recursive: true });
|
|
2033
|
+
|
|
2034
|
+
const sourceFiles = await writeResearchSourcesToFiles(
|
|
2035
|
+
fetchedSources,
|
|
2036
|
+
sourcesDir,
|
|
2037
|
+
);
|
|
2038
|
+
const gaps = uniqueStrings([
|
|
2039
|
+
...allGaps,
|
|
2040
|
+
...rounds.flatMap((round) => round.gaps || []),
|
|
2041
|
+
]);
|
|
2042
|
+
writeFileSync(
|
|
2043
|
+
join(dir, "STATUS.md"),
|
|
2044
|
+
[
|
|
2045
|
+
floor.floorMet ? "STATUS: DONE" : "STATUS: PARTIAL",
|
|
2046
|
+
"",
|
|
2047
|
+
`Query: ${query}`,
|
|
2048
|
+
`Stop reason: ${manifest.terminationReason || "max_rounds"}`,
|
|
2049
|
+
"",
|
|
2050
|
+
"## Deterministic floor checks",
|
|
2051
|
+
...Object.entries(floor.checks).map(
|
|
2052
|
+
([name, ok]) => `- [${ok ? "x" : " "}] ${name}`,
|
|
2053
|
+
),
|
|
2054
|
+
"",
|
|
2055
|
+
"## Questions",
|
|
2056
|
+
renderQuestionStatus(questions),
|
|
2057
|
+
"",
|
|
2058
|
+
"## Open gaps",
|
|
2059
|
+
markdownList(gaps),
|
|
2060
|
+
"",
|
|
2061
|
+
].join("\n"),
|
|
2062
|
+
"utf8",
|
|
2063
|
+
);
|
|
2064
|
+
writeFileSync(
|
|
2065
|
+
join(dir, "OUTLINE.md"),
|
|
2066
|
+
[
|
|
2067
|
+
"# Research bundle outline",
|
|
2068
|
+
"",
|
|
2069
|
+
"- `reports/SUMMARY.md` — final cited report",
|
|
2070
|
+
"- `reports/CLAIMS.md` — extracted claims with support/source IDs",
|
|
2071
|
+
"- `reports/EVIDENCE.md` — goal-based source evidence",
|
|
2072
|
+
"- `reports/GAPS.md` — remaining caveats and uncertainties",
|
|
2073
|
+
"- `provenance.md` — human-readable run metadata and verification",
|
|
2074
|
+
"- `sources/` — fetched source markdown files",
|
|
2075
|
+
"- `data/manifest.json` — machine-readable run metadata",
|
|
2076
|
+
"- `data/rounds.json` — per-round actions/learnings/gaps",
|
|
2077
|
+
"- `data/sources.json` — ranked source registry",
|
|
2078
|
+
"- `data/questions.json` — open/closed question ledger",
|
|
2079
|
+
"",
|
|
2080
|
+
].join("\n"),
|
|
2081
|
+
"utf8",
|
|
2082
|
+
);
|
|
2083
|
+
writeFileSync(
|
|
2084
|
+
join(reportsDir, "SUMMARY.md"),
|
|
2085
|
+
String(synthesis.answer || ""),
|
|
2086
|
+
"utf8",
|
|
2087
|
+
);
|
|
2088
|
+
writeFileSync(
|
|
2089
|
+
join(reportsDir, "CLAIMS.md"),
|
|
2090
|
+
[
|
|
2091
|
+
"# Key claims",
|
|
2092
|
+
"",
|
|
2093
|
+
...(Array.isArray(synthesis.claims) && synthesis.claims.length
|
|
2094
|
+
? synthesis.claims.map((claim) => {
|
|
2095
|
+
const ids = Array.isArray(claim.sourceIds)
|
|
2096
|
+
? claim.sourceIds.join(", ")
|
|
2097
|
+
: "";
|
|
2098
|
+
return `- ${claim.claim || ""} (${claim.support || "support unknown"}${ids ? `; ${ids}` : ""})`;
|
|
2099
|
+
})
|
|
2100
|
+
: ["No structured claims were extracted."]),
|
|
2101
|
+
"",
|
|
2102
|
+
].join("\n"),
|
|
2103
|
+
"utf8",
|
|
2104
|
+
);
|
|
2105
|
+
writeFileSync(
|
|
2106
|
+
join(reportsDir, "EVIDENCE.md"),
|
|
2107
|
+
[
|
|
2108
|
+
"# Extracted evidence",
|
|
2109
|
+
"",
|
|
2110
|
+
...(evidenceItems.length
|
|
2111
|
+
? evidenceItems.map((item) =>
|
|
2112
|
+
[
|
|
2113
|
+
`## ${item.sourceId || item.url || "Source"}`,
|
|
2114
|
+
item.url ? `<${item.url}>` : "",
|
|
2115
|
+
item.rational ? `**Rational:** ${item.rational}` : "",
|
|
2116
|
+
item.evidence ? `**Evidence:** ${item.evidence}` : "",
|
|
2117
|
+
item.summary ? `**Summary:** ${item.summary}` : "",
|
|
2118
|
+
"",
|
|
2119
|
+
]
|
|
2120
|
+
.filter(Boolean)
|
|
2121
|
+
.join("\n"),
|
|
2122
|
+
)
|
|
2123
|
+
: ["No goal-based evidence was extracted."]),
|
|
2124
|
+
"",
|
|
2125
|
+
].join("\n"),
|
|
2126
|
+
"utf8",
|
|
2127
|
+
);
|
|
2128
|
+
writeFileSync(
|
|
2129
|
+
join(reportsDir, "GAPS.md"),
|
|
2130
|
+
[
|
|
2131
|
+
"# Gaps and caveats",
|
|
2132
|
+
"",
|
|
2133
|
+
"## Caveats",
|
|
2134
|
+
markdownList(synthesis.caveats || []),
|
|
2135
|
+
"",
|
|
2136
|
+
"## Research gaps",
|
|
2137
|
+
markdownList(gaps),
|
|
2138
|
+
"",
|
|
2139
|
+
].join("\n"),
|
|
2140
|
+
"utf8",
|
|
2141
|
+
);
|
|
2142
|
+
writeFileSync(
|
|
2143
|
+
join(dataDir, "manifest.json"),
|
|
2144
|
+
JSON.stringify({ ...manifest, floor, citationAudit }, null, 2),
|
|
2145
|
+
"utf8",
|
|
2146
|
+
);
|
|
2147
|
+
writeFileSync(
|
|
2148
|
+
join(dataDir, "rounds.json"),
|
|
2149
|
+
JSON.stringify(rounds, null, 2),
|
|
2150
|
+
"utf8",
|
|
2151
|
+
);
|
|
2152
|
+
writeFileSync(
|
|
2153
|
+
join(dataDir, "sources.json"),
|
|
2154
|
+
JSON.stringify(sources, null, 2),
|
|
2155
|
+
"utf8",
|
|
2156
|
+
);
|
|
2157
|
+
writeFileSync(
|
|
2158
|
+
join(dataDir, "questions.json"),
|
|
2159
|
+
JSON.stringify(questions, null, 2),
|
|
2160
|
+
"utf8",
|
|
2161
|
+
);
|
|
2162
|
+
writeFileSync(
|
|
2163
|
+
join(dataDir, "evidence.json"),
|
|
2164
|
+
JSON.stringify(evidenceItems, null, 2),
|
|
2165
|
+
"utf8",
|
|
2166
|
+
);
|
|
2167
|
+
writeFileSync(
|
|
2168
|
+
join(sourcesDir, "index.md"),
|
|
2169
|
+
[
|
|
2170
|
+
"# Source index",
|
|
2171
|
+
"",
|
|
2172
|
+
...sourceFiles.map((source) => {
|
|
2173
|
+
const label = source.title || source.url;
|
|
2174
|
+
const url = source.finalUrl || source.url;
|
|
2175
|
+
const path = source.contentPath ? ` — ${source.contentPath}` : "";
|
|
2176
|
+
return `- ${source.id || "?"}: [${label}](${url})${path}`;
|
|
2177
|
+
}),
|
|
2178
|
+
"",
|
|
2179
|
+
].join("\n"),
|
|
2180
|
+
"utf8",
|
|
2181
|
+
);
|
|
2182
|
+
|
|
2183
|
+
// Provenance sidecar — human-readable run metadata (non-critical)
|
|
2184
|
+
try {
|
|
2185
|
+
writeProvenanceSidecar(dir, {
|
|
2186
|
+
query,
|
|
2187
|
+
rounds,
|
|
2188
|
+
sources,
|
|
2189
|
+
fetchedSources,
|
|
2190
|
+
citationAudit,
|
|
2191
|
+
citationUrls,
|
|
2192
|
+
floor,
|
|
2193
|
+
manifest,
|
|
2194
|
+
});
|
|
2195
|
+
} catch (sidecarError) {
|
|
2196
|
+
process.stderr.write(
|
|
2197
|
+
`[greedysearch] Provenance sidecar write failed (non-critical): ${sidecarError.message}\n`,
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
return {
|
|
2202
|
+
dir,
|
|
2203
|
+
statusPath: join(dir, "STATUS.md"),
|
|
2204
|
+
summaryPath: join(reportsDir, "SUMMARY.md"),
|
|
2205
|
+
manifestPath: join(dataDir, "manifest.json"),
|
|
2206
|
+
provenancePath: join(dir, "provenance.md"),
|
|
2207
|
+
sourceCount: sourceFiles.length,
|
|
2208
|
+
sourceFiles,
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
|
|
1144
2212
|
export async function runResearchMode({
|
|
1145
2213
|
query,
|
|
1146
2214
|
breadth = 3,
|
|
@@ -1149,14 +2217,63 @@ export async function runResearchMode({
|
|
|
1149
2217
|
locale = null,
|
|
1150
2218
|
short = false,
|
|
1151
2219
|
qualityThreshold = 8.5,
|
|
2220
|
+
writeBundle = process.env.GREEDY_RESEARCH_BUNDLE !== "0",
|
|
2221
|
+
researchOutDir = null,
|
|
1152
2222
|
} = {}) {
|
|
1153
2223
|
const options = clampResearchOptions({ breadth, iterations, maxSources });
|
|
2224
|
+
|
|
2225
|
+
// ── Scale-aware fast path ────────────────────────────────────────────────
|
|
2226
|
+
// When breadth and iterations are at defaults (not user-specified), classify
|
|
2227
|
+
// the query complexity. Simple queries bypass the iterative loop entirely
|
|
2228
|
+
// for ~70% faster results and lower API cost.
|
|
2229
|
+
const userSpecifiedBreadth = typeof breadth === "number";
|
|
2230
|
+
const userSpecifiedIterations = typeof iterations === "number";
|
|
2231
|
+
const atDefaults = !userSpecifiedBreadth && !userSpecifiedIterations;
|
|
2232
|
+
|
|
2233
|
+
if (atDefaults) {
|
|
2234
|
+
try {
|
|
2235
|
+
const classification = await classifyResearchComplexity(query);
|
|
2236
|
+
process.stderr.write(
|
|
2237
|
+
`[greedysearch] Complexity: ${classification.complexity} (${classification.reasoning})\n`,
|
|
2238
|
+
);
|
|
2239
|
+
if (classification.complexity === "simple") {
|
|
2240
|
+
process.stderr.write(
|
|
2241
|
+
`[greedysearch] Simple query detected — using fast single-pass path\n`,
|
|
2242
|
+
);
|
|
2243
|
+
return runSimpleResearchMode({
|
|
2244
|
+
query,
|
|
2245
|
+
locale,
|
|
2246
|
+
maxSources: Math.min(maxSources ?? 5, 5),
|
|
2247
|
+
qualityThreshold,
|
|
2248
|
+
writeBundle,
|
|
2249
|
+
researchOutDir,
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
// For moderate/complex: use classifier suggestions as hints if user
|
|
2253
|
+
// didn't specify values. This tightens the loop for moderate queries
|
|
2254
|
+
// without changing the user-explicit path.
|
|
2255
|
+
if (!userSpecifiedBreadth) {
|
|
2256
|
+
options.breadth = classification.suggestedBreadth;
|
|
2257
|
+
}
|
|
2258
|
+
if (!userSpecifiedIterations) {
|
|
2259
|
+
options.iterations = classification.suggestedIterations;
|
|
2260
|
+
}
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
process.stderr.write(
|
|
2263
|
+
`[greedysearch] Scale classification failed, using defaults: ${error.message}\n`,
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
1154
2268
|
const rounds = [];
|
|
1155
2269
|
let allLearnings = [];
|
|
1156
2270
|
let allGaps = [];
|
|
2271
|
+
const questions = createQuestionLedger(query);
|
|
1157
2272
|
let activeActions = null;
|
|
1158
2273
|
let combinedSources = [];
|
|
1159
2274
|
let fetchedSources = [];
|
|
2275
|
+
let evidenceItems = [];
|
|
2276
|
+
const extractedSourceKeys = new Set();
|
|
1160
2277
|
const usedQueries = new Set();
|
|
1161
2278
|
const usedUrls = new Set();
|
|
1162
2279
|
const qualityHistory = [];
|
|
@@ -1170,8 +2287,20 @@ export async function runResearchMode({
|
|
|
1170
2287
|
let totalFetches = 0;
|
|
1171
2288
|
const engineFailures = [];
|
|
1172
2289
|
|
|
2290
|
+
// Progress bar with ETA — pre-compute totals from plan so the bar
|
|
2291
|
+
// reflects the full run, not just the current round. The actual
|
|
2292
|
+
// actions per round come from Gemini's plan; we estimate 1 fetch
|
|
2293
|
+
// per academic source found.
|
|
2294
|
+
const progressTracker = createProgressTracker({
|
|
2295
|
+
totalActions: options.iterations * options.breadth,
|
|
2296
|
+
totalRounds: options.iterations,
|
|
2297
|
+
totalFetches: options.iterations, // estimate: ~1 fetch per round
|
|
2298
|
+
silent: process.env.GREEDY_RESEARCH_QUIET === "1",
|
|
2299
|
+
});
|
|
2300
|
+
progressTracker.startRound(1);
|
|
2301
|
+
|
|
1173
2302
|
process.stderr.write(
|
|
1174
|
-
`[greedysearch] Research mode: breadth ${options.breadth}, iterations ${options.iterations}, qualityThreshold ${qualityThreshold}\n`,
|
|
2303
|
+
`[greedysearch] Research mode: breadth ${options.breadth}, iterations ${options.iterations}, qualityThreshold ${qualityThreshold}, engines ${RESEARCH_ENGINES.join(",")}, synthesizer gemini\n`,
|
|
1175
2304
|
);
|
|
1176
2305
|
|
|
1177
2306
|
for (let roundIndex = 0; roundIndex < options.iterations; roundIndex++) {
|
|
@@ -1254,6 +2383,26 @@ export async function runResearchMode({
|
|
|
1254
2383
|
});
|
|
1255
2384
|
|
|
1256
2385
|
const roundActions = noveltyFiltered.slice(0, roundBreadth);
|
|
2386
|
+
|
|
2387
|
+
// Force at least one fetchUrl per round when a known academic source
|
|
2388
|
+
// (arXiv, semantic-scholar, DOI) is present in combinedSources. The
|
|
2389
|
+
// Gemini planner occasionally emits all-search actions even when the
|
|
2390
|
+
// answer is in a single arXiv PDF; direct fetching gives the synthesizer
|
|
2391
|
+
// real PDF text and reliably passes citation audits.
|
|
2392
|
+
const academicTargets = pickAcademicFetchTargets(combinedSources, usedUrls);
|
|
2393
|
+
const hasFetch = roundActions.some((a) => a.type === "fetchUrl");
|
|
2394
|
+
if (!hasFetch && academicTargets.length > 0) {
|
|
2395
|
+
const injectTarget = academicTargets[0];
|
|
2396
|
+
roundActions.push({
|
|
2397
|
+
type: "fetchUrl",
|
|
2398
|
+
url: injectTarget.url,
|
|
2399
|
+
researchGoal: `Direct fetch of known academic source: ${injectTarget.label || injectTarget.url}`,
|
|
2400
|
+
});
|
|
2401
|
+
process.stderr.write(
|
|
2402
|
+
`[greedysearch] Forced fetchUrl for academic source: ${injectTarget.url}\n`,
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
1257
2406
|
const actionRuns = [];
|
|
1258
2407
|
for (let i = 0; i < roundActions.length; i++) {
|
|
1259
2408
|
const action = roundActions[i];
|
|
@@ -1263,6 +2412,10 @@ export async function runResearchMode({
|
|
|
1263
2412
|
process.stderr.write(
|
|
1264
2413
|
`[greedysearch] Action ${i + 1}/${roundActions.length} [${action.type}]: ${(action.query || action.url).slice(0, 80)}\n`,
|
|
1265
2414
|
);
|
|
2415
|
+
progressTracker.startAction(
|
|
2416
|
+
action.type,
|
|
2417
|
+
(action.query || action.url || "").slice(0, 60),
|
|
2418
|
+
);
|
|
1266
2419
|
const run = await executeResearchAction(action, {
|
|
1267
2420
|
locale,
|
|
1268
2421
|
short,
|
|
@@ -1270,10 +2423,14 @@ export async function runResearchMode({
|
|
|
1270
2423
|
usedUrls,
|
|
1271
2424
|
maxChars: 8000,
|
|
1272
2425
|
});
|
|
2426
|
+
progressTracker.endAction();
|
|
1273
2427
|
actionRuns.push(run);
|
|
1274
2428
|
totalActionsRun++;
|
|
1275
2429
|
if (action.type === "search") totalSearches++;
|
|
1276
|
-
if (action.type === "fetchUrl")
|
|
2430
|
+
if (action.type === "fetchUrl") {
|
|
2431
|
+
totalFetches++;
|
|
2432
|
+
progressTracker.endFetch(run.ok);
|
|
2433
|
+
}
|
|
1277
2434
|
if (!run.ok) {
|
|
1278
2435
|
engineFailures.push({
|
|
1279
2436
|
round: roundNumber,
|
|
@@ -1292,6 +2449,7 @@ export async function runResearchMode({
|
|
|
1292
2449
|
const fetchActionRuns = actionRuns.filter(
|
|
1293
2450
|
(r) => r.action.type === "fetchUrl",
|
|
1294
2451
|
);
|
|
2452
|
+
updateQuestionLedger(questions, { roundNumber, actions: actionRuns });
|
|
1295
2453
|
|
|
1296
2454
|
combinedSources = dedupeSources([
|
|
1297
2455
|
combinedSources,
|
|
@@ -1329,6 +2487,33 @@ export async function runResearchMode({
|
|
|
1329
2487
|
fetchedSources,
|
|
1330
2488
|
);
|
|
1331
2489
|
}
|
|
2490
|
+
fetchedSources = annotateFetchedSourcesWithIds(
|
|
2491
|
+
fetchedSources,
|
|
2492
|
+
combinedSources,
|
|
2493
|
+
);
|
|
2494
|
+
|
|
2495
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:evidence\n`);
|
|
2496
|
+
const evidenceRun = await extractEvidenceFromSources({
|
|
2497
|
+
query,
|
|
2498
|
+
questions,
|
|
2499
|
+
fetchedSources,
|
|
2500
|
+
extractedSourceKeys,
|
|
2501
|
+
});
|
|
2502
|
+
if (evidenceRun.error) {
|
|
2503
|
+
process.stderr.write(
|
|
2504
|
+
`[greedysearch] Evidence extraction failed: ${evidenceRun.error}\n`,
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
evidenceItems = [...evidenceItems, ...evidenceRun.evidence];
|
|
2508
|
+
for (const evidence of evidenceRun.evidence) {
|
|
2509
|
+
updateQuestionLedger(questions, {
|
|
2510
|
+
roundNumber,
|
|
2511
|
+
learningPayload: {
|
|
2512
|
+
answeredQuestions: evidence.answers || [],
|
|
2513
|
+
newQuestions: evidence.newQuestions || [],
|
|
2514
|
+
},
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
1332
2517
|
|
|
1333
2518
|
// Build round query summary for learning extraction
|
|
1334
2519
|
const roundQueries = actionRuns.map((run) => ({
|
|
@@ -1351,6 +2536,8 @@ export async function runResearchMode({
|
|
|
1351
2536
|
engines: summarizeEngineAnswers(run.result),
|
|
1352
2537
|
})),
|
|
1353
2538
|
fetchedSources,
|
|
2539
|
+
questions,
|
|
2540
|
+
evidenceItems,
|
|
1354
2541
|
),
|
|
1355
2542
|
{ timeoutMs: 120000 },
|
|
1356
2543
|
);
|
|
@@ -1377,8 +2564,14 @@ export async function runResearchMode({
|
|
|
1377
2564
|
.filter(Boolean)
|
|
1378
2565
|
.slice(0, 6)
|
|
1379
2566
|
: [];
|
|
1380
|
-
allLearnings =
|
|
1381
|
-
allGaps =
|
|
2567
|
+
allLearnings = uniqueStrings([...allLearnings, ...learnings]);
|
|
2568
|
+
allGaps = uniqueStrings([...allGaps, ...gaps]);
|
|
2569
|
+
updateQuestionLedger(questions, {
|
|
2570
|
+
roundNumber,
|
|
2571
|
+
actions: [],
|
|
2572
|
+
learningPayload,
|
|
2573
|
+
gaps,
|
|
2574
|
+
});
|
|
1382
2575
|
rounds.push({
|
|
1383
2576
|
round: roundNumber,
|
|
1384
2577
|
actions: actionRuns.map((run) => ({
|
|
@@ -1391,11 +2584,17 @@ export async function runResearchMode({
|
|
|
1391
2584
|
})),
|
|
1392
2585
|
learnings,
|
|
1393
2586
|
gaps,
|
|
2587
|
+
evidence: evidenceRun.evidence,
|
|
2588
|
+
evidenceError: evidenceRun.error,
|
|
1394
2589
|
learningError,
|
|
1395
2590
|
});
|
|
1396
2591
|
|
|
1397
2592
|
// Quality evaluation
|
|
1398
2593
|
process.stderr.write(`PROGRESS:research:round-${roundNumber}:evaluating\n`);
|
|
2594
|
+
progressTracker.endRound();
|
|
2595
|
+
if (roundNumber < options.iterations) {
|
|
2596
|
+
progressTracker.startRound(roundNumber + 1);
|
|
2597
|
+
}
|
|
1399
2598
|
const evaluation = await evaluateResearchQuality(
|
|
1400
2599
|
query,
|
|
1401
2600
|
rounds,
|
|
@@ -1404,19 +2603,38 @@ export async function runResearchMode({
|
|
|
1404
2603
|
qualityHistory,
|
|
1405
2604
|
);
|
|
1406
2605
|
qualityHistory.push(evaluation.score);
|
|
2606
|
+
allGaps = uniqueStrings([...allGaps, ...(evaluation.knowledgeGaps || [])]);
|
|
2607
|
+
updateQuestionLedger(questions, {
|
|
2608
|
+
roundNumber,
|
|
2609
|
+
gaps: evaluation.knowledgeGaps || [],
|
|
2610
|
+
});
|
|
2611
|
+
const preliminaryFloor = computeResearchFloor({
|
|
2612
|
+
sources: combinedSources,
|
|
2613
|
+
fetchedSources,
|
|
2614
|
+
gaps: allGaps,
|
|
2615
|
+
questions,
|
|
2616
|
+
rounds,
|
|
2617
|
+
qualityScore: evaluation.score,
|
|
2618
|
+
qualityThreshold,
|
|
2619
|
+
maxSources: options.maxSources,
|
|
2620
|
+
requireCitations: false,
|
|
2621
|
+
requireQuestions: false,
|
|
2622
|
+
});
|
|
1407
2623
|
process.stderr.write(
|
|
1408
|
-
`[greedysearch] Quality score round ${roundNumber}: ${evaluation.score.toFixed(1)} (shouldContinue: ${evaluation.shouldContinue})\n`,
|
|
2624
|
+
`[greedysearch] Quality score round ${roundNumber}: ${evaluation.score.toFixed(1)} (shouldContinue: ${evaluation.shouldContinue}, floor: ${preliminaryFloor.floorMet})\n`,
|
|
1409
2625
|
);
|
|
1410
2626
|
|
|
1411
|
-
// Early termination
|
|
2627
|
+
// Early termination is outcome-first: Gemini quality alone is not enough.
|
|
2628
|
+
// Stop early only when the score is high AND deterministic source/floor checks pass.
|
|
1412
2629
|
if (
|
|
1413
2630
|
evaluation.score >= qualityThreshold &&
|
|
2631
|
+
preliminaryFloor.floorMet &&
|
|
1414
2632
|
(!evaluation.shouldContinue ||
|
|
1415
2633
|
evaluation.terminationReason === "quality_threshold")
|
|
1416
2634
|
) {
|
|
1417
2635
|
terminationReason = evaluation.terminationReason || "quality_threshold";
|
|
1418
2636
|
process.stderr.write(
|
|
1419
|
-
`[greedysearch]
|
|
2637
|
+
`[greedysearch] Research floor reached (score: ${evaluation.score.toFixed(1)}). Terminating early.\n`,
|
|
1420
2638
|
);
|
|
1421
2639
|
break;
|
|
1422
2640
|
}
|
|
@@ -1490,16 +2708,26 @@ export async function runResearchMode({
|
|
|
1490
2708
|
};
|
|
1491
2709
|
try {
|
|
1492
2710
|
const rawReport = await runGeminiPrompt(
|
|
1493
|
-
buildFinalReportPrompt(
|
|
2711
|
+
buildFinalReportPrompt(
|
|
2712
|
+
query,
|
|
2713
|
+
rounds,
|
|
2714
|
+
combinedSources,
|
|
2715
|
+
questions,
|
|
2716
|
+
evidenceItems,
|
|
2717
|
+
),
|
|
1494
2718
|
{ timeoutMs: 180000 },
|
|
1495
2719
|
);
|
|
1496
2720
|
const parsed = parseGeminiJson(rawReport, {});
|
|
2721
|
+
const hasClaims = Array.isArray(parsed?.claims) && parsed.claims.length > 0;
|
|
1497
2722
|
synthesis = {
|
|
1498
2723
|
...synthesis,
|
|
1499
2724
|
...parsed,
|
|
1500
2725
|
rawAnswer: rawReport.answer || "",
|
|
1501
2726
|
geminiSources: rawReport.sources || [],
|
|
1502
|
-
synthesized
|
|
2727
|
+
// Only mark as synthesized if Gemini actually returned structured
|
|
2728
|
+
// claims. An empty/minimal response should not block the evidence
|
|
2729
|
+
// fallback from running.
|
|
2730
|
+
synthesized: hasClaims,
|
|
1503
2731
|
};
|
|
1504
2732
|
} catch (error) {
|
|
1505
2733
|
process.stderr.write(
|
|
@@ -1508,15 +2736,128 @@ export async function runResearchMode({
|
|
|
1508
2736
|
synthesis.error = error.message;
|
|
1509
2737
|
}
|
|
1510
2738
|
|
|
1511
|
-
|
|
2739
|
+
// Fallback: when no structured learnings were produced but per-source
|
|
2740
|
+
// evidence was extracted successfully, ask Gemini to synthesize a final
|
|
2741
|
+
// report directly from the evidence. This rescues runs whose per-round
|
|
2742
|
+
// learning prompt failed (e.g. transient Gemini input field rejection)
|
|
2743
|
+
// but whose evidence extraction step still captured real data.
|
|
2744
|
+
const hasStructuredSynthesis =
|
|
2745
|
+
synthesis.synthesized === true &&
|
|
2746
|
+
Array.isArray(synthesis.claims) &&
|
|
2747
|
+
synthesis.claims.length > 0;
|
|
2748
|
+
if (!hasStructuredSynthesis && evidenceItems.length > 0) {
|
|
2749
|
+
process.stderr.write(
|
|
2750
|
+
"[greedysearch] Falling back to evidence-based synthesis (no per-round learnings).\n",
|
|
2751
|
+
);
|
|
2752
|
+
try {
|
|
2753
|
+
const evidencePrompt = buildSynthesisFromEvidencePrompt(
|
|
2754
|
+
query,
|
|
2755
|
+
combinedSources,
|
|
2756
|
+
questions,
|
|
2757
|
+
evidenceItems,
|
|
2758
|
+
);
|
|
2759
|
+
const rawEvidenceReport = await runGeminiPrompt(evidencePrompt, {
|
|
2760
|
+
timeoutMs: 180000,
|
|
2761
|
+
});
|
|
2762
|
+
const parsedEvidence = parseGeminiJson(rawEvidenceReport, {});
|
|
2763
|
+
synthesis = {
|
|
2764
|
+
...synthesis,
|
|
2765
|
+
...parsedEvidence,
|
|
2766
|
+
rawAnswer: rawEvidenceReport.answer || synthesis.answer || "",
|
|
2767
|
+
geminiSources:
|
|
2768
|
+
rawEvidenceReport.sources || synthesis.geminiSources || [],
|
|
2769
|
+
synthesized: true,
|
|
2770
|
+
synthesisMode: "evidence_fallback",
|
|
2771
|
+
};
|
|
2772
|
+
} catch (error) {
|
|
2773
|
+
process.stderr.write(
|
|
2774
|
+
`[greedysearch] Evidence-based synthesis failed: ${error.message}\n`,
|
|
2775
|
+
);
|
|
2776
|
+
synthesis.evidenceFallbackError = error.message;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
1512
2780
|
const finishedAt = new Date().toISOString();
|
|
1513
2781
|
const durationMs = Date.now() - startMs;
|
|
2782
|
+
const qualityScore = qualityHistory.at(-1) || 0;
|
|
2783
|
+
fetchedSources = annotateFetchedSourcesWithIds(
|
|
2784
|
+
fetchedSources,
|
|
2785
|
+
combinedSources,
|
|
2786
|
+
);
|
|
1514
2787
|
|
|
1515
|
-
// Citation audit
|
|
2788
|
+
// Citation audit + final question reconciliation + deterministic completion floor
|
|
1516
2789
|
process.stderr.write("PROGRESS:research:audit-citations\n");
|
|
1517
2790
|
const citationAudit = auditCitations(synthesis.answer || "", combinedSources);
|
|
1518
2791
|
|
|
2792
|
+
// Citation URL reachability check
|
|
2793
|
+
const citationUrls = await runCitationUrlCheck(combinedSources);
|
|
2794
|
+
|
|
2795
|
+
reconcileQuestionsFromSynthesis(questions, synthesis, citationAudit);
|
|
2796
|
+
const floor = computeResearchFloor({
|
|
2797
|
+
sources: combinedSources,
|
|
2798
|
+
fetchedSources,
|
|
2799
|
+
synthesis,
|
|
2800
|
+
citationAudit,
|
|
2801
|
+
gaps: allGaps,
|
|
2802
|
+
questions,
|
|
2803
|
+
rounds,
|
|
2804
|
+
qualityScore,
|
|
2805
|
+
qualityThreshold,
|
|
2806
|
+
maxSources: options.maxSources,
|
|
2807
|
+
});
|
|
2808
|
+
if (floor.floorMet && terminationReason === "max_rounds") {
|
|
2809
|
+
terminationReason = "done_floor_met";
|
|
2810
|
+
} else if (!floor.floorMet && terminationReason === "quality_threshold") {
|
|
2811
|
+
terminationReason = "max_rounds_floor_unmet";
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
const manifest = {
|
|
2815
|
+
startedAt,
|
|
2816
|
+
finishedAt,
|
|
2817
|
+
durationMs,
|
|
2818
|
+
engines: RESEARCH_ENGINES,
|
|
2819
|
+
synthesizer: "gemini",
|
|
2820
|
+
rounds: rounds.length,
|
|
2821
|
+
actionsRun: totalActionsRun,
|
|
2822
|
+
searches: totalSearches,
|
|
2823
|
+
fetches: totalFetches,
|
|
2824
|
+
sourcesFetched: fetchedSources.filter((s) => s?.contentChars > 100).length,
|
|
2825
|
+
engineFailures,
|
|
2826
|
+
terminationReason,
|
|
2827
|
+
floorMet: floor.floorMet,
|
|
2828
|
+
};
|
|
2829
|
+
let bundle = null;
|
|
2830
|
+
let fetchedFiles;
|
|
2831
|
+
if (writeBundle) {
|
|
2832
|
+
process.stderr.write("PROGRESS:research:bundle\n");
|
|
2833
|
+
try {
|
|
2834
|
+
bundle = await writeResearchBundle({
|
|
2835
|
+
query,
|
|
2836
|
+
rounds,
|
|
2837
|
+
sources: combinedSources,
|
|
2838
|
+
fetchedSources,
|
|
2839
|
+
evidenceItems,
|
|
2840
|
+
synthesis,
|
|
2841
|
+
citationAudit,
|
|
2842
|
+
citationUrls,
|
|
2843
|
+
floor,
|
|
2844
|
+
manifest,
|
|
2845
|
+
allGaps,
|
|
2846
|
+
questions,
|
|
2847
|
+
outDir: researchOutDir,
|
|
2848
|
+
});
|
|
2849
|
+
fetchedFiles = bundle.sourceFiles;
|
|
2850
|
+
delete bundle.sourceFiles;
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
bundle = { error: error.message || String(error) };
|
|
2853
|
+
fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
|
|
2854
|
+
}
|
|
2855
|
+
} else {
|
|
2856
|
+
fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
|
|
2857
|
+
}
|
|
2858
|
+
|
|
1519
2859
|
process.stderr.write("PROGRESS:research:done\n");
|
|
2860
|
+
progressTracker.finish();
|
|
1520
2861
|
|
|
1521
2862
|
return {
|
|
1522
2863
|
query,
|
|
@@ -1527,23 +2868,19 @@ export async function runResearchMode({
|
|
|
1527
2868
|
maxSources: options.maxSources,
|
|
1528
2869
|
rounds,
|
|
1529
2870
|
learnings: allLearnings,
|
|
2871
|
+
gaps: allGaps,
|
|
2872
|
+
evidence: evidenceItems,
|
|
2873
|
+
questions,
|
|
2874
|
+
questionProgress: questionProgress(questions),
|
|
1530
2875
|
qualityHistory,
|
|
1531
2876
|
terminationReason,
|
|
1532
2877
|
qualityThreshold,
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
durationMs,
|
|
1537
|
-
rounds: rounds.length,
|
|
1538
|
-
actionsRun: totalActionsRun,
|
|
1539
|
-
searches: totalSearches,
|
|
1540
|
-
fetches: totalFetches,
|
|
1541
|
-
sourcesFetched: fetchedSources.filter((s) => s?.contentChars > 100)
|
|
1542
|
-
.length,
|
|
1543
|
-
engineFailures,
|
|
1544
|
-
},
|
|
2878
|
+
floor,
|
|
2879
|
+
bundle,
|
|
2880
|
+
manifest,
|
|
1545
2881
|
},
|
|
1546
2882
|
_citationAudit: citationAudit,
|
|
2883
|
+
_citationUrls: citationUrls,
|
|
1547
2884
|
_sources: combinedSources,
|
|
1548
2885
|
_fetchedSources: fetchedFiles,
|
|
1549
2886
|
_synthesis: synthesis,
|
|
@@ -1559,23 +2896,43 @@ export async function runResearchMode({
|
|
|
1559
2896
|
)
|
|
1560
2897
|
: 0,
|
|
1561
2898
|
agreementLevel: synthesis.agreement?.level || "mixed",
|
|
2899
|
+
floorMet: floor.floorMet,
|
|
1562
2900
|
},
|
|
1563
2901
|
};
|
|
1564
2902
|
}
|
|
1565
2903
|
|
|
1566
2904
|
function dedupeFetchedSources(sources) {
|
|
1567
|
-
const
|
|
2905
|
+
const byUrl = new Map();
|
|
1568
2906
|
for (const source of sources) {
|
|
1569
2907
|
const key =
|
|
1570
2908
|
source?.id || normalizeUrl(source?.finalUrl || source?.url || "");
|
|
1571
2909
|
if (!key) continue;
|
|
1572
|
-
const existing =
|
|
2910
|
+
const existing = byUrl.get(key);
|
|
1573
2911
|
if (
|
|
1574
2912
|
!existing ||
|
|
1575
2913
|
(source.contentChars || 0) > (existing.contentChars || 0)
|
|
1576
2914
|
) {
|
|
1577
|
-
|
|
2915
|
+
byUrl.set(key, source);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
const out = [];
|
|
2920
|
+
for (const source of byUrl.values()) {
|
|
2921
|
+
const content = String(source.content || source.snippet || "");
|
|
2922
|
+
const duplicateIndex = out.findIndex((existing) => {
|
|
2923
|
+
const other = String(existing.content || existing.snippet || "");
|
|
2924
|
+
if (content.length < 400 || other.length < 400) return false;
|
|
2925
|
+
return (
|
|
2926
|
+
jaccardSimilarity(content.slice(0, 4000), other.slice(0, 4000)) >= 0.9
|
|
2927
|
+
);
|
|
2928
|
+
});
|
|
2929
|
+
if (duplicateIndex === -1) {
|
|
2930
|
+
out.push(source);
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
if ((source.contentChars || 0) > (out[duplicateIndex].contentChars || 0)) {
|
|
2934
|
+
out[duplicateIndex] = source;
|
|
1578
2935
|
}
|
|
1579
2936
|
}
|
|
1580
|
-
return
|
|
2937
|
+
return out;
|
|
1581
2938
|
}
|