webpeel 0.20.2 → 0.20.3
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/dist/server/app.d.ts +14 -0
- package/dist/server/app.js +384 -0
- package/dist/server/auth-store.d.ts +27 -0
- package/dist/server/auth-store.js +88 -0
- package/dist/server/email-service.d.ts +21 -0
- package/dist/server/email-service.js +79 -0
- package/dist/server/job-queue.d.ts +100 -0
- package/dist/server/job-queue.js +145 -0
- package/dist/server/logger.d.ts +10 -0
- package/dist/server/logger.js +37 -0
- package/dist/server/middleware/auth.d.ts +28 -0
- package/dist/server/middleware/auth.js +221 -0
- package/dist/server/middleware/rate-limit.d.ts +24 -0
- package/dist/server/middleware/rate-limit.js +167 -0
- package/dist/server/middleware/url-validator.d.ts +15 -0
- package/dist/server/middleware/url-validator.js +186 -0
- package/dist/server/openapi.yaml +6418 -0
- package/dist/server/pg-auth-store.d.ts +132 -0
- package/dist/server/pg-auth-store.js +472 -0
- package/dist/server/pg-job-queue.d.ts +59 -0
- package/dist/server/pg-job-queue.js +375 -0
- package/dist/server/premium/domain-intel.d.ts +16 -0
- package/dist/server/premium/domain-intel.js +133 -0
- package/dist/server/premium/index.d.ts +17 -0
- package/dist/server/premium/index.js +35 -0
- package/dist/server/premium/swr-cache.d.ts +14 -0
- package/dist/server/premium/swr-cache.js +34 -0
- package/dist/server/routes/activity.d.ts +6 -0
- package/dist/server/routes/activity.js +74 -0
- package/dist/server/routes/answer.d.ts +5 -0
- package/dist/server/routes/answer.js +125 -0
- package/dist/server/routes/ask.d.ts +28 -0
- package/dist/server/routes/ask.js +229 -0
- package/dist/server/routes/batch.d.ts +6 -0
- package/dist/server/routes/batch.js +493 -0
- package/dist/server/routes/cli-usage.d.ts +6 -0
- package/dist/server/routes/cli-usage.js +127 -0
- package/dist/server/routes/compat.d.ts +23 -0
- package/dist/server/routes/compat.js +652 -0
- package/dist/server/routes/deep-fetch.d.ts +8 -0
- package/dist/server/routes/deep-fetch.js +57 -0
- package/dist/server/routes/demo.d.ts +24 -0
- package/dist/server/routes/demo.js +517 -0
- package/dist/server/routes/do.d.ts +8 -0
- package/dist/server/routes/do.js +72 -0
- package/dist/server/routes/extract.d.ts +8 -0
- package/dist/server/routes/extract.js +235 -0
- package/dist/server/routes/fetch.d.ts +7 -0
- package/dist/server/routes/fetch.js +999 -0
- package/dist/server/routes/health.d.ts +7 -0
- package/dist/server/routes/health.js +19 -0
- package/dist/server/routes/jobs.d.ts +7 -0
- package/dist/server/routes/jobs.js +573 -0
- package/dist/server/routes/mcp.d.ts +14 -0
- package/dist/server/routes/mcp.js +141 -0
- package/dist/server/routes/oauth.d.ts +9 -0
- package/dist/server/routes/oauth.js +396 -0
- package/dist/server/routes/playground.d.ts +17 -0
- package/dist/server/routes/playground.js +283 -0
- package/dist/server/routes/screenshot.d.ts +22 -0
- package/dist/server/routes/screenshot.js +816 -0
- package/dist/server/routes/search.d.ts +6 -0
- package/dist/server/routes/search.js +303 -0
- package/dist/server/routes/session.d.ts +15 -0
- package/dist/server/routes/session.js +397 -0
- package/dist/server/routes/stats.d.ts +6 -0
- package/dist/server/routes/stats.js +71 -0
- package/dist/server/routes/stripe.d.ts +15 -0
- package/dist/server/routes/stripe.js +294 -0
- package/dist/server/routes/users.d.ts +8 -0
- package/dist/server/routes/users.js +1671 -0
- package/dist/server/routes/watch.d.ts +15 -0
- package/dist/server/routes/watch.js +309 -0
- package/dist/server/routes/webhooks.d.ts +26 -0
- package/dist/server/routes/webhooks.js +170 -0
- package/dist/server/routes/youtube.d.ts +6 -0
- package/dist/server/routes/youtube.js +130 -0
- package/dist/server/sentry.d.ts +13 -0
- package/dist/server/sentry.js +38 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.js +7 -0
- package/dist/server/utils/response.d.ts +44 -0
- package/dist/server/utils/response.js +69 -0
- package/dist/server/utils/sse.d.ts +22 -0
- package/dist/server/utils/sse.js +38 -0
- package/package.json +2 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity endpoint - provides recent API request history
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { PostgresAuthStore } from '../pg-auth-store.js';
|
|
6
|
+
export function createActivityRouter(authStore) {
|
|
7
|
+
const router = Router();
|
|
8
|
+
router.get('/v1/activity', async (req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
// Require authentication (API key or JWT session token)
|
|
11
|
+
const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
12
|
+
if (!userId) {
|
|
13
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'Authentication required', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#unauthorized' }, requestId: req.requestId });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Only works with PostgreSQL backend
|
|
17
|
+
if (!(authStore instanceof PostgresAuthStore)) {
|
|
18
|
+
res.status(501).json({
|
|
19
|
+
success: false,
|
|
20
|
+
error: {
|
|
21
|
+
type: 'not_implemented',
|
|
22
|
+
message: 'Activity endpoint requires PostgreSQL backend',
|
|
23
|
+
docs: 'https://webpeel.dev/docs/errors#not_implemented',
|
|
24
|
+
},
|
|
25
|
+
requestId: req.requestId,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Access pool via any cast (pool is private but we need direct DB access)
|
|
30
|
+
const pgStore = authStore;
|
|
31
|
+
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
|
|
32
|
+
// Get recent requests from usage_logs
|
|
33
|
+
const activityQuery = `
|
|
34
|
+
SELECT
|
|
35
|
+
id,
|
|
36
|
+
url,
|
|
37
|
+
method,
|
|
38
|
+
status_code,
|
|
39
|
+
processing_time_ms,
|
|
40
|
+
tokens_used,
|
|
41
|
+
created_at
|
|
42
|
+
FROM usage_logs
|
|
43
|
+
WHERE user_id = $1
|
|
44
|
+
ORDER BY created_at DESC
|
|
45
|
+
LIMIT $2
|
|
46
|
+
`;
|
|
47
|
+
const result = await pgStore.pool.query(activityQuery, [userId, limit]);
|
|
48
|
+
// Transform to frontend format
|
|
49
|
+
const requests = result.rows.map((row) => ({
|
|
50
|
+
id: row.id,
|
|
51
|
+
url: row.url || 'N/A',
|
|
52
|
+
status: (row.status_code >= 200 && row.status_code < 300) ? 'success' : 'error',
|
|
53
|
+
responseTime: row.processing_time_ms || 0,
|
|
54
|
+
mode: row.method || 'basic',
|
|
55
|
+
timestamp: row.created_at,
|
|
56
|
+
tokensUsed: row.tokens_used || null,
|
|
57
|
+
}));
|
|
58
|
+
res.json({ requests });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error('Activity error:', error);
|
|
62
|
+
res.status(500).json({
|
|
63
|
+
success: false,
|
|
64
|
+
error: {
|
|
65
|
+
type: 'internal_error',
|
|
66
|
+
message: 'Failed to retrieve activity',
|
|
67
|
+
docs: 'https://webpeel.dev/docs/errors#internal_error',
|
|
68
|
+
},
|
|
69
|
+
requestId: req.requestId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return router;
|
|
74
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /v1/answer — search + fetch + LLM-generated answer with citations (BYOK)
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { answerQuestion, } from '../../core/answer.js';
|
|
6
|
+
const VALID_LLM_PROVIDERS = ['openai', 'anthropic', 'google'];
|
|
7
|
+
const VALID_SEARCH_PROVIDERS = ['duckduckgo', 'brave'];
|
|
8
|
+
export function createAnswerRouter() {
|
|
9
|
+
const router = Router();
|
|
10
|
+
router.post('/v1/answer', async (req, res) => {
|
|
11
|
+
// Deprecation notice — prefer /v1/fetch?question=... which is LLM-free
|
|
12
|
+
res.setHeader('X-Deprecated', 'true');
|
|
13
|
+
res.setHeader('X-Deprecated-Use', '/v1/fetch?question=...');
|
|
14
|
+
// AUTH: require authentication (global middleware sets req.auth)
|
|
15
|
+
const ansAuthId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
16
|
+
if (!ansAuthId) {
|
|
17
|
+
res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required. Get one at https://app.webpeel.dev/keys', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#authentication_required' }, requestId: req.requestId });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const { question, searchProvider, searchApiKey, llmProvider, llmApiKey, llmModel, maxSources, stream, } = req.body;
|
|
22
|
+
// --- Validation -----------------------------------------------------------
|
|
23
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
24
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "question" parameter', hint: 'Include a "question" string in the request body', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (question.length > 2000) {
|
|
28
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: '"question" too long (max 2000 characters)', hint: 'Keep the question under 2000 characters', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!llmProvider || !VALID_LLM_PROVIDERS.includes(llmProvider)) {
|
|
32
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: `"llmProvider" is required and must be one of: ${VALID_LLM_PROVIDERS.join(', ')}`, hint: `Supported providers: ${VALID_LLM_PROVIDERS.join(', ')}`, docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!llmApiKey || typeof llmApiKey !== 'string' || llmApiKey.trim().length === 0) {
|
|
36
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "llmApiKey" (BYOK required)', hint: 'Provide your own LLM API key in the "llmApiKey" field', docs: 'https://webpeel.dev/docs/errors#invalid_request' }, requestId: req.requestId });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const resolvedSearchProvider = searchProvider && VALID_SEARCH_PROVIDERS.includes(searchProvider)
|
|
40
|
+
? searchProvider
|
|
41
|
+
: 'duckduckgo';
|
|
42
|
+
// Accept search API key from body or header
|
|
43
|
+
const resolvedSearchApiKey = searchApiKey || req.headers['x-search-api-key'] || undefined;
|
|
44
|
+
const resolvedMaxSources = typeof maxSources === 'number'
|
|
45
|
+
? Math.min(Math.max(maxSources, 1), 10)
|
|
46
|
+
: 5;
|
|
47
|
+
const shouldStream = stream === true;
|
|
48
|
+
// --- Streaming response (SSE) -------------------------------------------
|
|
49
|
+
if (shouldStream) {
|
|
50
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
51
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
52
|
+
res.setHeader('Connection', 'keep-alive');
|
|
53
|
+
res.setHeader('X-Accel-Buffering', 'no'); // nginx
|
|
54
|
+
res.flushHeaders();
|
|
55
|
+
const answerReq = {
|
|
56
|
+
question: question.trim(),
|
|
57
|
+
searchProvider: resolvedSearchProvider,
|
|
58
|
+
searchApiKey: resolvedSearchApiKey,
|
|
59
|
+
llmProvider: llmProvider,
|
|
60
|
+
llmApiKey: llmApiKey.trim(),
|
|
61
|
+
llmModel,
|
|
62
|
+
maxSources: resolvedMaxSources,
|
|
63
|
+
stream: true,
|
|
64
|
+
onChunk: (text) => {
|
|
65
|
+
const payload = JSON.stringify({ type: 'chunk', text });
|
|
66
|
+
res.write(`data: ${payload}\n\n`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const result = await answerQuestion(answerReq);
|
|
71
|
+
const donePayload = JSON.stringify({
|
|
72
|
+
type: 'done',
|
|
73
|
+
citations: result.citations,
|
|
74
|
+
searchProvider: result.searchProvider,
|
|
75
|
+
llmProvider: result.llmProvider,
|
|
76
|
+
llmModel: result.llmModel,
|
|
77
|
+
tokensUsed: result.tokensUsed,
|
|
78
|
+
});
|
|
79
|
+
res.write(`data: ${donePayload}\n\n`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const errMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
83
|
+
const errPayload = JSON.stringify({ type: 'error', message: errMsg });
|
|
84
|
+
res.write(`data: ${errPayload}\n\n`);
|
|
85
|
+
}
|
|
86
|
+
res.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// --- Non-streaming response ---------------------------------------------
|
|
90
|
+
const answerReq = {
|
|
91
|
+
question: question.trim(),
|
|
92
|
+
searchProvider: resolvedSearchProvider,
|
|
93
|
+
searchApiKey: resolvedSearchApiKey,
|
|
94
|
+
llmProvider: llmProvider,
|
|
95
|
+
llmApiKey: llmApiKey.trim(),
|
|
96
|
+
llmModel,
|
|
97
|
+
maxSources: resolvedMaxSources,
|
|
98
|
+
stream: false,
|
|
99
|
+
};
|
|
100
|
+
const result = await answerQuestion(answerReq);
|
|
101
|
+
res.json({
|
|
102
|
+
answer: result.answer,
|
|
103
|
+
citations: result.citations,
|
|
104
|
+
searchProvider: result.searchProvider,
|
|
105
|
+
llmProvider: result.llmProvider,
|
|
106
|
+
llmModel: result.llmModel,
|
|
107
|
+
tokensUsed: result.tokensUsed,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const err = error;
|
|
112
|
+
console.error('Answer error:', err);
|
|
113
|
+
res.status(500).json({
|
|
114
|
+
success: false,
|
|
115
|
+
error: {
|
|
116
|
+
type: 'answer_failed',
|
|
117
|
+
message: 'Failed to generate answer. Please try again.',
|
|
118
|
+
docs: 'https://webpeel.dev/docs/errors#answer_failed',
|
|
119
|
+
},
|
|
120
|
+
requestId: req.requestId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return router;
|
|
125
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /v1/ask?q=<question>&sources=3
|
|
3
|
+
* POST /v1/ask { "question": "...", "sources": 3 }
|
|
4
|
+
*
|
|
5
|
+
* LLM-free web Q&A: search → fetch top pages → BM25 → best answer
|
|
6
|
+
*
|
|
7
|
+
* Returns:
|
|
8
|
+
* {
|
|
9
|
+
* question: string,
|
|
10
|
+
* answer: string, // best passage from top sources
|
|
11
|
+
* confidence: number, // 0-1
|
|
12
|
+
* sources: [{url, title, snippet, confidence}],
|
|
13
|
+
* method: "bm25",
|
|
14
|
+
* elapsed: number // ms
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* No LLM key required — 100% deterministic BM25 ranking.
|
|
18
|
+
* Competitors: Tavily charges $50/mo and requires an API key.
|
|
19
|
+
* We do this with zero LLM cost, included in every plan.
|
|
20
|
+
*
|
|
21
|
+
* Performance targets:
|
|
22
|
+
* - Source pages fetched in parallel with 5s timeout (no browser escalation)
|
|
23
|
+
* - Early termination when high-confidence answer found (>=0.85)
|
|
24
|
+
* - 10s hard timeout on the entire flow
|
|
25
|
+
* - 5-minute in-memory cache for repeated questions
|
|
26
|
+
*/
|
|
27
|
+
import { Router } from 'express';
|
|
28
|
+
export declare function createAskRouter(): Router;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /v1/ask?q=<question>&sources=3
|
|
3
|
+
* POST /v1/ask { "question": "...", "sources": 3 }
|
|
4
|
+
*
|
|
5
|
+
* LLM-free web Q&A: search → fetch top pages → BM25 → best answer
|
|
6
|
+
*
|
|
7
|
+
* Returns:
|
|
8
|
+
* {
|
|
9
|
+
* question: string,
|
|
10
|
+
* answer: string, // best passage from top sources
|
|
11
|
+
* confidence: number, // 0-1
|
|
12
|
+
* sources: [{url, title, snippet, confidence}],
|
|
13
|
+
* method: "bm25",
|
|
14
|
+
* elapsed: number // ms
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* No LLM key required — 100% deterministic BM25 ranking.
|
|
18
|
+
* Competitors: Tavily charges $50/mo and requires an API key.
|
|
19
|
+
* We do this with zero LLM cost, included in every plan.
|
|
20
|
+
*
|
|
21
|
+
* Performance targets:
|
|
22
|
+
* - Source pages fetched in parallel with 5s timeout (no browser escalation)
|
|
23
|
+
* - Early termination when high-confidence answer found (>=0.85)
|
|
24
|
+
* - 10s hard timeout on the entire flow
|
|
25
|
+
* - 5-minute in-memory cache for repeated questions
|
|
26
|
+
*/
|
|
27
|
+
import { Router } from 'express';
|
|
28
|
+
import { peel } from '../../index.js';
|
|
29
|
+
import { quickAnswer } from '../../core/quick-answer.js';
|
|
30
|
+
import { getBestSearchProvider } from '../../core/search-provider.js';
|
|
31
|
+
const resultCache = new Map();
|
|
32
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
33
|
+
function getCacheKey(question, numSources) {
|
|
34
|
+
return `${question.trim().toLowerCase()}|${numSources}`;
|
|
35
|
+
}
|
|
36
|
+
function getFromCache(key) {
|
|
37
|
+
const entry = resultCache.get(key);
|
|
38
|
+
if (!entry)
|
|
39
|
+
return null;
|
|
40
|
+
if (Date.now() > entry.expiresAt) {
|
|
41
|
+
resultCache.delete(key);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return entry.result;
|
|
45
|
+
}
|
|
46
|
+
function setInCache(key, result) {
|
|
47
|
+
// Evict stale entries periodically (simple GC — keep max 500 entries)
|
|
48
|
+
if (resultCache.size >= 500) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const [k, v] of resultCache) {
|
|
51
|
+
if (v.expiresAt < now)
|
|
52
|
+
resultCache.delete(k);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
resultCache.set(key, { result, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Route factory
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function createAskRouter() {
|
|
61
|
+
const router = Router();
|
|
62
|
+
async function handleAsk(question, numSources, req, res) {
|
|
63
|
+
const startMs = Date.now();
|
|
64
|
+
const elapsed = () => Date.now() - startMs;
|
|
65
|
+
if (!question?.trim()) {
|
|
66
|
+
res.status(400).json({ success: false, error: { type: 'missing_question', message: 'Provide q= or question= parameter', hint: 'GET /v1/ask?q=your+question or POST {"question": "your question"}', docs: 'https://webpeel.dev/docs/errors#missing_question' }, requestId: req.requestId });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Auth check — global middleware sets req.auth
|
|
70
|
+
const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
71
|
+
if (!authId) {
|
|
72
|
+
res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required. Get one at https://app.webpeel.dev/keys', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#authentication_required' }, requestId: req.requestId });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const clampedSources = Math.min(Math.max(numSources, 1), 5);
|
|
76
|
+
// Cache check — return cached result immediately for repeated questions
|
|
77
|
+
const cacheKey = getCacheKey(question, clampedSources);
|
|
78
|
+
const cached = getFromCache(cacheKey);
|
|
79
|
+
if (cached) {
|
|
80
|
+
if (process.env.DEBUG)
|
|
81
|
+
console.debug('[ask] cache hit in', elapsed(), 'ms');
|
|
82
|
+
res.json({ ...cached, elapsed: elapsed() });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
// Total flow timeout — 10s hard cap.
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
const TOTAL_TIMEOUT_MS = 10000;
|
|
89
|
+
let timedOut = false;
|
|
90
|
+
const totalTimer = setTimeout(() => { timedOut = true; }, TOTAL_TIMEOUT_MS);
|
|
91
|
+
try {
|
|
92
|
+
// Step 1: Search
|
|
93
|
+
const searchStart = Date.now();
|
|
94
|
+
const { provider, apiKey } = getBestSearchProvider();
|
|
95
|
+
let searchResults;
|
|
96
|
+
try {
|
|
97
|
+
searchResults = await provider.searchWeb(question.trim(), {
|
|
98
|
+
count: clampedSources,
|
|
99
|
+
apiKey,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
searchResults = [];
|
|
104
|
+
}
|
|
105
|
+
if (process.env.DEBUG)
|
|
106
|
+
console.debug(`[ask] search ${Date.now() - searchStart}ms, ${searchResults.length} results`);
|
|
107
|
+
if (!searchResults.length) {
|
|
108
|
+
clearTimeout(totalTimer);
|
|
109
|
+
res.json({
|
|
110
|
+
question,
|
|
111
|
+
answer: null,
|
|
112
|
+
confidence: 0,
|
|
113
|
+
sources: [],
|
|
114
|
+
method: 'bm25',
|
|
115
|
+
elapsed: elapsed(),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
// Step 2: Fetch top sources in parallel
|
|
121
|
+
// - noEscalate: true → skip browser escalation (simple HTTP only)
|
|
122
|
+
// - render: false → don't start headless browser
|
|
123
|
+
// - timeout: 5000 → 5s per source max
|
|
124
|
+
// - budget: 3000 → keep content manageable
|
|
125
|
+
// -----------------------------------------------------------------------
|
|
126
|
+
const PER_SOURCE_TIMEOUT_MS = 5000;
|
|
127
|
+
const fetchStart = Date.now();
|
|
128
|
+
const sourceUrls = searchResults.slice(0, clampedSources);
|
|
129
|
+
const fetchPromises = sourceUrls.map((r) => Promise.race([
|
|
130
|
+
peel(r.url, {
|
|
131
|
+
render: false,
|
|
132
|
+
noEscalate: true,
|
|
133
|
+
format: 'markdown',
|
|
134
|
+
timeout: PER_SOURCE_TIMEOUT_MS,
|
|
135
|
+
budget: 3000,
|
|
136
|
+
}).then((result) => ({ result, searchResult: r })),
|
|
137
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('per-source timeout')), PER_SOURCE_TIMEOUT_MS)),
|
|
138
|
+
]));
|
|
139
|
+
const fetched = await Promise.allSettled(fetchPromises);
|
|
140
|
+
if (process.env.DEBUG) {
|
|
141
|
+
const ok = fetched.filter(f => f.status === 'fulfilled').length;
|
|
142
|
+
console.debug(`[ask] fetch ${Date.now() - fetchStart}ms, ${ok}/${sourceUrls.length} ok`);
|
|
143
|
+
}
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// Step 3: Score with quickAnswer, sort by confidence
|
|
146
|
+
// Early termination: if any source yields >=0.85 confidence, use it now
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
const HIGH_CONFIDENCE_THRESHOLD = 0.85;
|
|
149
|
+
const answers = [];
|
|
150
|
+
for (const f of fetched) {
|
|
151
|
+
if (timedOut)
|
|
152
|
+
break;
|
|
153
|
+
if (f.status !== 'fulfilled')
|
|
154
|
+
continue;
|
|
155
|
+
const { result, searchResult } = f.value;
|
|
156
|
+
const qa = quickAnswer({
|
|
157
|
+
question,
|
|
158
|
+
content: result.content,
|
|
159
|
+
url: result.url,
|
|
160
|
+
maxPassages: 2,
|
|
161
|
+
});
|
|
162
|
+
answers.push({
|
|
163
|
+
answer: qa.answer,
|
|
164
|
+
confidence: qa.confidence,
|
|
165
|
+
source: {
|
|
166
|
+
url: result.url,
|
|
167
|
+
title: result.title || searchResult.title,
|
|
168
|
+
snippet: searchResult.snippet,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// Early termination on high confidence
|
|
172
|
+
if (qa.confidence >= HIGH_CONFIDENCE_THRESHOLD) {
|
|
173
|
+
if (process.env.DEBUG)
|
|
174
|
+
console.debug(`[ask] early exit confidence=${qa.confidence.toFixed(2)} at ${elapsed()}ms`);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
answers.sort((a, b) => b.confidence - a.confidence);
|
|
179
|
+
const best = answers[0];
|
|
180
|
+
clearTimeout(totalTimer);
|
|
181
|
+
const response = {
|
|
182
|
+
question,
|
|
183
|
+
answer: best?.answer || null,
|
|
184
|
+
confidence: best?.confidence || 0,
|
|
185
|
+
sources: answers.map((a) => ({
|
|
186
|
+
...a.source,
|
|
187
|
+
confidence: a.confidence,
|
|
188
|
+
})),
|
|
189
|
+
method: 'bm25',
|
|
190
|
+
elapsed: elapsed(),
|
|
191
|
+
};
|
|
192
|
+
if (timedOut) {
|
|
193
|
+
response.warning = 'Partial result — 10s timeout reached';
|
|
194
|
+
}
|
|
195
|
+
// Cache successful results (only when we have an answer)
|
|
196
|
+
if (best?.answer && !timedOut) {
|
|
197
|
+
setInCache(cacheKey, response);
|
|
198
|
+
}
|
|
199
|
+
if (process.env.DEBUG)
|
|
200
|
+
console.debug(`[ask] done ${elapsed()}ms confidence=${best?.confidence?.toFixed(2) ?? 0}`);
|
|
201
|
+
res.json(response);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
clearTimeout(totalTimer);
|
|
205
|
+
if (process.env.DEBUG)
|
|
206
|
+
console.debug('[ask] error:', err);
|
|
207
|
+
res.json({
|
|
208
|
+
question,
|
|
209
|
+
answer: null,
|
|
210
|
+
confidence: 0,
|
|
211
|
+
sources: [],
|
|
212
|
+
method: 'bm25',
|
|
213
|
+
elapsed: elapsed(),
|
|
214
|
+
...(timedOut ? { warning: 'Request timed out after 10s' } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
router.get('/v1/ask', async (req, res) => {
|
|
219
|
+
const question = req.query.q || req.query.question || '';
|
|
220
|
+
const sources = Math.min(parseInt(req.query.sources || '3', 10) || 3, 5);
|
|
221
|
+
await handleAsk(question, sources, req, res);
|
|
222
|
+
});
|
|
223
|
+
router.post('/v1/ask', async (req, res) => {
|
|
224
|
+
const question = req.body?.question || req.body?.q || '';
|
|
225
|
+
const sources = Math.min(parseInt(req.body?.sources ?? 3, 10) || 3, 5);
|
|
226
|
+
await handleAsk(question, sources, req, res);
|
|
227
|
+
});
|
|
228
|
+
return router;
|
|
229
|
+
}
|