token count for search
This commit is contained in:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exa-js": "^2.11.0",
|
"exa-js": "^2.11.0",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"tiktoken": "^1.0.22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openrouter/sdk": {
|
"node_modules/@openrouter/sdk": {
|
||||||
@@ -889,6 +890,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiktoken": {
|
||||||
|
"version": "1.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
|
||||||
|
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exa-js": "^2.11.0",
|
"exa-js": "^2.11.0",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"tiktoken": "^1.0.22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,67 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cost Breakdown Styles */
|
||||||
|
.cost-breakdown-details {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-breakdown-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
padding: 12px 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
transition: background 0.2s;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-breakdown-summary:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-breakdown-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-breakdown-content {
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table th,
|
||||||
|
.cost-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-table tbody tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background: #fee;
|
background: #fee;
|
||||||
color: #c00;
|
color: #c00;
|
||||||
@@ -464,6 +525,7 @@
|
|||||||
<button class="example-btn" onclick="setExample('Wie ist die Lage im Iran?')">Lage im Iran</button>
|
<button class="example-btn" onclick="setExample('Wie ist die Lage im Iran?')">Lage im Iran</button>
|
||||||
<button class="example-btn" onclick="setExample('Welche KI Modelle wurden in den letzten Tagen veröffentlicht?')">Neue KI-Modelle</button>
|
<button class="example-btn" onclick="setExample('Welche KI Modelle wurden in den letzten Tagen veröffentlicht?')">Neue KI-Modelle</button>
|
||||||
<button class="example-btn" onclick="setExample('Wie ist das Wetter in Dresden?')">Wetter in Dresden</button>
|
<button class="example-btn" onclick="setExample('Wie ist das Wetter in Dresden?')">Wetter in Dresden</button>
|
||||||
|
<button class="example-btn" onclick="setExample('Was ist neu in React 19.2?')">React 19.2</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
@@ -493,6 +555,29 @@
|
|||||||
<h2>🔗 Most Relevant Sources</h2>
|
<h2>🔗 Most Relevant Sources</h2>
|
||||||
<ul class="sources" id="sources"></ul>
|
<ul class="sources" id="sources"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Breakdown Section -->
|
||||||
|
<div class="result-section">
|
||||||
|
<details class="cost-breakdown-details" id="costBreakdownDetails">
|
||||||
|
<summary class="cost-breakdown-summary">💰 Cost Breakdown</summary>
|
||||||
|
<div class="cost-breakdown-content">
|
||||||
|
<table class="cost-table" id="costTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Input</th>
|
||||||
|
<th>Output</th>
|
||||||
|
<th>Cost (USD)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="costTableBody">
|
||||||
|
<!-- Cost rows will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Panel -->
|
<!-- Log Panel -->
|
||||||
@@ -531,6 +616,10 @@
|
|||||||
// SSE connection
|
// SSE connection
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let logEntries = [];
|
let logEntries = [];
|
||||||
|
|
||||||
|
// Clarification state
|
||||||
|
let isAwaitingClarification = false;
|
||||||
|
let clarificationData = null;
|
||||||
|
|
||||||
function connectToSSE() {
|
function connectToSSE() {
|
||||||
eventSource = new EventSource('/stream');
|
eventSource = new EventSource('/stream');
|
||||||
@@ -679,8 +768,109 @@
|
|||||||
searchBtn.disabled = false;
|
searchBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function performSearchWithClarification(clarificationText) {
|
||||||
|
if (!clarificationData || !clarificationData.originalQuestion) {
|
||||||
|
showError('Clarification data not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
hideError();
|
||||||
|
hideResults();
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question: clarificationData.originalQuestion + ' ' + clarificationText,
|
||||||
|
previousClarification: clarificationText,
|
||||||
|
originalQuestion: clarificationData.originalQuestion
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
displayResults(data);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function displayResults(data) {
|
function displayResults(data) {
|
||||||
|
// Check if clarification is needed
|
||||||
|
if (data.clarificationNeeded) {
|
||||||
|
isAwaitingClarification = true;
|
||||||
|
clarificationData = {
|
||||||
|
originalQuestion: data.originalQuestion
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show clarification prompt in the answer area
|
||||||
|
answerEl.innerHTML = `
|
||||||
|
<div style="background: #fff3cd; padding: 20px; border-radius: 8px; border-left: 4px solid #ffc107;">
|
||||||
|
<p style="margin-bottom: 15px; font-weight: 600;">${data.fullAnswerHTMLSnippet}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="clarificationInput"
|
||||||
|
class="search-input"
|
||||||
|
style="margin-bottom: 10px;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="search-btn"
|
||||||
|
id="clarificationSubmitBtn"
|
||||||
|
style="padding: 10px 20px; font-size: 1rem;"
|
||||||
|
>
|
||||||
|
Submit Clarification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Clear sources since we're waiting for clarification
|
||||||
|
sourcesEl.innerHTML = '<li>Waiting for clarification...</li>';
|
||||||
|
|
||||||
|
// Add event listener for clarification submission
|
||||||
|
setTimeout(() => {
|
||||||
|
const clarificationInput = document.getElementById('clarificationInput');
|
||||||
|
const clarificationSubmitBtn = document.getElementById('clarificationSubmitBtn');
|
||||||
|
|
||||||
|
if (clarificationInput && clarificationSubmitBtn) {
|
||||||
|
clarificationInput.focus();
|
||||||
|
|
||||||
|
const handleClarification = () => {
|
||||||
|
const clarificationText = clarificationInput.value.trim();
|
||||||
|
if (clarificationText) {
|
||||||
|
// Search again with the clarification
|
||||||
|
performSearchWithClarification(clarificationText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clarificationSubmitBtn.addEventListener('click', handleClarification);
|
||||||
|
clarificationInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleClarification();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
showResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clarification state for normal results
|
||||||
|
isAwaitingClarification = false;
|
||||||
|
clarificationData = null;
|
||||||
|
|
||||||
// Display answer as HTML
|
// Display answer as HTML
|
||||||
answerEl.innerHTML = data.fullAnswerHTMLSnippet || '<p>No answer generated</p>';
|
answerEl.innerHTML = data.fullAnswerHTMLSnippet || '<p>No answer generated</p>';
|
||||||
|
|
||||||
@@ -703,6 +893,27 @@
|
|||||||
sourcesEl.appendChild(li);
|
sourcesEl.appendChild(li);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display cost breakdown
|
||||||
|
const costTableBody = document.getElementById('costTableBody');
|
||||||
|
costTableBody.innerHTML = '';
|
||||||
|
if (data.costBreakdown && data.costBreakdown.length > 0) {
|
||||||
|
data.costBreakdown.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${item.index}</td>
|
||||||
|
<td>${item.type}</td>
|
||||||
|
<td>${item.prompt_tokens || '-'}</td>
|
||||||
|
<td>${item.completion_tokens || '-'}</td>
|
||||||
|
<td>${item.cost}</td>
|
||||||
|
`;
|
||||||
|
costTableBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = '<td colspan="5">No cost data available</td>';
|
||||||
|
costTableBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
showResults();
|
showResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ function createSimpleBroadcast() {
|
|||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
const prefix = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : type === 'success' ? '✅' : 'ℹ️';
|
const prefix = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : type === 'success' ? '✅' : 'ℹ️';
|
||||||
console.log(`[${timestamp}] ${prefix} ${message}`);
|
console.log(`[${timestamp}] ${prefix} ${message}`);
|
||||||
if (data) {
|
|
||||||
console.log(` Data: ${JSON.stringify(data, null, 2)}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function createSearchRouter(searchService, broadcast) {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/', async (request, response) => {
|
router.post('/', async (request, response) => {
|
||||||
const { question } = request.body;
|
const { question, previousClarification, originalQuestion } = request.body;
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
response.status(400).json({
|
response.status(400).json({
|
||||||
@@ -16,7 +16,7 @@ export function createSearchRouter(searchService, broadcast) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await searchService.search(question);
|
const result = await searchService.search(question, previousClarification, originalQuestion);
|
||||||
response.json(result);
|
response.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SearchServiceError) {
|
if (error instanceof SearchServiceError) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function formatTimestamp() {
|
|||||||
* @param {Object} response - The response from OpenRouter
|
* @param {Object} response - The response from OpenRouter
|
||||||
* @param {Error} [error] - Optional error if the call failed
|
* @param {Error} [error] - Optional error if the call failed
|
||||||
*/
|
*/
|
||||||
export async function logOpenRouterCall(operation, params, response, error = null) {
|
export async function logOpenRouterCall(operation, text, params, response, error = null) {
|
||||||
try {
|
try {
|
||||||
await ensureLogDirectory();
|
await ensureLogDirectory();
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export async function logOpenRouterCall(operation, params, response, error = nul
|
|||||||
|
|
||||||
const logContent = `${timestamp} | ${operation}\n\nParams:\n${JSON.stringify(params, null, 2)}\n\nResponse:\n${JSON.stringify(response, null, 2)}${error ? `\n\nError: ${error.message}` : ''}\n`;
|
const logContent = `${timestamp} | ${operation}\n\nParams:\n${JSON.stringify(params, null, 2)}\n\nResponse:\n${JSON.stringify(response, null, 2)}${error ? `\n\nError: ${error.message}` : ''}\n`;
|
||||||
|
|
||||||
await fs.writeFile(logFilePath, logContent, 'utf8');
|
await fs.writeFile(logFilePath, text + logContent, 'utf8');
|
||||||
} catch (logError) {
|
} catch (logError) {
|
||||||
console.error('Failed to log OpenRouter call:', logError);
|
console.error('Failed to log OpenRouter call:', logError);
|
||||||
// Don't throw - logging should not break the main functionality
|
// Don't throw - logging should not break the main functionality
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { logOpenRouterCall } from './openRouterLogger.js';
|
|||||||
|
|
||||||
function parseResponse(response) {
|
function parseResponse(response) {
|
||||||
if(!response?.usage?.cost) console.log(response);
|
if(!response?.usage?.cost) console.log(response);
|
||||||
|
console.log('OpenRouter API call cost:', response?.usage);
|
||||||
return { cost: response?.usage?.cost, data: JSON.parse(response.choices[0].message.content) };
|
return {
|
||||||
|
cost: response?.usage?.cost,
|
||||||
|
prompt_tokens: response?.usage?.prompt_tokens,
|
||||||
|
completion_tokens: response?.usage?.completion_tokens,
|
||||||
|
data: JSON.parse(response.choices[0].message.content)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function summarizeSources({ openrouter, text, question }) {
|
export async function summarizeSources({ openrouter, text, question }) {
|
||||||
@@ -60,7 +65,95 @@ export async function summarizeSources({ openrouter, text, question }) {
|
|||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
});
|
});
|
||||||
const response = await fetchResponse.json();
|
const response = await fetchResponse.json();
|
||||||
await logOpenRouterCall('summarizeSources', params, response);
|
await logOpenRouterCall('summarizeSources', text, params, response);
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rephraseQuestion({ question, previousClarification, originalQuestion }) {
|
||||||
|
|
||||||
|
if(previousClarification) {
|
||||||
|
const prompt = `
|
||||||
|
You are a search query expert. You are given a question and you return
|
||||||
|
a search query for a web search engine that would return the best results to answer the question.
|
||||||
|
Do NOT restrict the query using site: operator.
|
||||||
|
Also give a list of 2 supplementary search queries to deepen the search.
|
||||||
|
Today is the date of ${new Date().toLocaleDateString()}.
|
||||||
|
The user has provided this clarification "${previousClarification}" to the original question: ` + originalQuestion;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompt },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
reasoning: { effort: 'none' },
|
||||||
|
stream: false,
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema', json_schema: {
|
||||||
|
name: 'queries', strict: true, schema: {
|
||||||
|
required: [ 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
|
||||||
|
query: { type: 'string' },
|
||||||
|
supplementaryQueries: { type: 'array', items: { type: 'string' } },
|
||||||
|
} } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const response = await fetchResponse.json();
|
||||||
|
await logOpenRouterCall('rephraseQuestion', question,params, response);
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
You are a search query expert. You are given a question and you return
|
||||||
|
a search query for a web search engine that would return the best results to answer the question.
|
||||||
|
Do NOT restrict the query using site: operator.
|
||||||
|
Also give a list of 2 supplementary search queries to deepen the search.
|
||||||
|
Today is the date of ${new Date().toLocaleDateString()}.
|
||||||
|
The user cannot ask questions that a web search engine cannot answer.
|
||||||
|
Like "Who are you".
|
||||||
|
If the question is ambiguous or unsuited for a web search, you can ask for clarification.
|
||||||
|
${previousClarification ? `The user has provided this clarification to the original question: "${previousClarification}". Use this clarification to refine the search query.` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompt },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
reasoning: { effort: 'none' },
|
||||||
|
stream: false,
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema', json_schema: {
|
||||||
|
name: 'queries', strict: true, schema: {
|
||||||
|
required: ['needsClarification', 'clarification', 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
|
||||||
|
needsClarification: { type: 'boolean', description: 'Indicates if the question is ambiguous and needs clarification' },
|
||||||
|
clarification: { type: 'string', description: 'If needsClarification is true, this field contains the clarification question to ask the user. Otherwise, it is an empty string.' },
|
||||||
|
query: { type: 'string' },
|
||||||
|
supplementaryQueries: { type: 'array', items: { type: 'string' } },
|
||||||
|
} } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const response = await fetchResponse.json();
|
||||||
|
await logOpenRouterCall('rephraseQuestion', question,params, response);
|
||||||
return parseResponse(response);
|
return parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +161,8 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
|
|||||||
const prompt = `
|
const prompt = `
|
||||||
You are a search result analyst. Today is the date of ${new Date().toLocaleDateString()}.
|
You are a search result analyst. Today is the date of ${new Date().toLocaleDateString()}.
|
||||||
Based on the following search results for the query "${question}",
|
Based on the following search results for the query "${question}",
|
||||||
Summarize the search results to answer the original query. Use Emoji and HTML. Tags allowed: <b>, <i>, <u>, <ul>, <li>, <span style="color:...">, <p> <div> <hr/>
|
Summarize the search results to answer the original query. Use Emoji and HTML. Tags allowed: <b>, <i>, <u>, <pre>, <ul>, <li>, <span style="color:...">, <p> <div> <hr/>
|
||||||
Also provide the most relevant sources.
|
Also provide the most relevant sources. Answer in the language of the question.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
@@ -112,7 +205,7 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
|
|||||||
});
|
});
|
||||||
const response = await fetchResponse.json();
|
const response = await fetchResponse.json();
|
||||||
|
|
||||||
await logOpenRouterCall('summarizeFinalAnswer', params, response);
|
await logOpenRouterCall('summarizeFinalAnswer', text, params, response);
|
||||||
|
|
||||||
return parseResponse(response);
|
return parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function wrapText(text, maxLineLength = 68) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSummarySources(sources) {
|
export function formatSummarySources(sources,supplementaryResults=[]) {
|
||||||
let output = `\n${'='.repeat(70)}\n`;
|
let output = `\n${'='.repeat(70)}\n`;
|
||||||
output += ` ${'SUMMARY'.padStart(35).padEnd(69)}\n`;
|
output += ` ${'SUMMARY'.padStart(35).padEnd(69)}\n`;
|
||||||
output += `${'='.repeat(70)}\n`;
|
output += `${'='.repeat(70)}\n`;
|
||||||
@@ -38,6 +38,13 @@ export function formatSummarySources(sources) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (supplementaryResults.length > 0) {
|
||||||
|
supplementaryResults.forEach((result) => {
|
||||||
|
output += `\n${'-'.repeat(70)}\n`;
|
||||||
|
output += JSON.stringify(result, null, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
output += `\n${'='.repeat(70)}\n`;
|
output += `\n${'='.repeat(70)}\n`;
|
||||||
output += `Total sources: ${sources.length}\n`;
|
output += `Total sources: ${sources.length}\n`;
|
||||||
output += `${'='.repeat(70)}\n`;
|
output += `${'='.repeat(70)}\n`;
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { extractContent } from './extractContent.js';
|
import { extractContent } from './extractContent.js';
|
||||||
import { logExtraction } from './extractionLogger.js';
|
import { logExtraction } from './extractionLogger.js';
|
||||||
import { summarizeFinalAnswer, summarizeSources } from './openRouterService.js';
|
import { summarizeFinalAnswer, summarizeSources, rephraseQuestion } from './openRouterService.js';
|
||||||
import { formatSummarySources } from './searchFormatter.js';
|
import { formatSummarySources } from './searchFormatter.js';
|
||||||
|
import { get_encoding } from 'tiktoken';
|
||||||
|
|
||||||
|
const encoding = get_encoding('cl100k_base');
|
||||||
|
|
||||||
|
function countTokens(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
const tokens = encoding.encode(text);
|
||||||
|
return tokens.length;
|
||||||
|
}
|
||||||
|
|
||||||
const EXA_SEARCH_OPTIONS = {
|
const EXA_SEARCH_OPTIONS = {
|
||||||
numResults: 10,
|
numResults: 10,
|
||||||
@@ -72,18 +81,114 @@ function enrichSourcesWithDetails(sources, detailedContents) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to build cost breakdown
|
||||||
|
function buildCostBreakdown(cost) {
|
||||||
|
// Print cost breakdown as a formatted table
|
||||||
|
console.log('\n=== Cost Breakdown ===');
|
||||||
|
console.table(
|
||||||
|
cost.map((item, index) => ({
|
||||||
|
'#': index + 1,
|
||||||
|
Type: item.type,
|
||||||
|
'Cost (USD)': `$${item.amount.toFixed(6)}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const totalCost = cost.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
console.log(`Total: $${totalCost.toFixed(6)}\n`);
|
||||||
|
|
||||||
|
// Build cost breakdown table for API response
|
||||||
|
const costBreakdown = cost.map((item, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
type: item.type,
|
||||||
|
prompt_tokens: item.prompt_tokens,
|
||||||
|
completion_tokens: item.completion_tokens,
|
||||||
|
cost: `$${item.amount.toFixed(6)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add total to the breakdown
|
||||||
|
costBreakdown.push({
|
||||||
|
index: costBreakdown.length + 1,
|
||||||
|
type: 'Total',
|
||||||
|
cost: `$${totalCost.toFixed(6)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return costBreakdown;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSearchService({ exa, openrouter, broadcast }) {
|
export function createSearchService({ exa, openrouter, broadcast }) {
|
||||||
return {
|
return {
|
||||||
async search(question) {
|
async search(question, previousClarification, originalQuestion) {
|
||||||
broadcast(`🔍 Search request: "${question}"`, 'info', { question });
|
let finalSummary;
|
||||||
|
const cost=[];
|
||||||
|
let rephrasedQuestion;
|
||||||
|
|
||||||
broadcast('Searching with Exa...', 'info');
|
try {
|
||||||
const result = await exa.search(question, EXA_SEARCH_OPTIONS);
|
const rephraseResult = await rephraseQuestion({question, previousClarification, originalQuestion});
|
||||||
|
rephrasedQuestion = rephraseResult.data;
|
||||||
|
cost.push({
|
||||||
|
type:'openrouter_rephrase',
|
||||||
|
amount: rephraseResult.cost,
|
||||||
|
prompt_tokens: rephraseResult.prompt_tokens,
|
||||||
|
completion_tokens: rephraseResult.completion_tokens
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new SearchServiceError('Failed to generate summary', 500, error);
|
||||||
|
}
|
||||||
|
if (rephrasedQuestion.needsClarification) {
|
||||||
|
finalSummary = {
|
||||||
|
fullAnswerHTMLSnippet: rephrasedQuestion.clarification,
|
||||||
|
clarificationNeeded: true,
|
||||||
|
originalQuestion: question,
|
||||||
|
mostRelevantSources: [],
|
||||||
|
};
|
||||||
|
// Attach cost breakdown to the final summary
|
||||||
|
finalSummary.costBreakdown = buildCostBreakdown(cost);
|
||||||
|
|
||||||
|
return finalSummary;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to first 2 supplementary queries
|
||||||
|
const limitedQueries = rephrasedQuestion.supplementaryQueries.slice(0, 2);
|
||||||
|
//limitedQueries.push(rephrasedQuestion.query);
|
||||||
|
const supplementaryResults = [];
|
||||||
|
for (const item of limitedQueries) {
|
||||||
|
console.log('Supplementary Query:', item);
|
||||||
|
const result = await exa.search(item, {
|
||||||
|
numResults: 2,
|
||||||
|
type: 'auto',
|
||||||
|
contents: {
|
||||||
|
highlights: {
|
||||||
|
maxCharacters: 2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
supplementaryResults.push({
|
||||||
|
query: item,
|
||||||
|
highlights: result.results.map(r => r.highlights),
|
||||||
|
});
|
||||||
|
const outputTokens = countTokens(JSON.stringify(result.results));
|
||||||
|
cost.push({
|
||||||
|
type: 'exa_search_complement',
|
||||||
|
amount: result.costDollars.total,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: outputTokens
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
broadcast('Searching with Exa for '+rephrasedQuestion.query, 'info');
|
||||||
|
const result = await exa.search(rephrasedQuestion.query, EXA_SEARCH_OPTIONS);
|
||||||
broadcast(`✅ Found ${result.results.length} results`, 'success', {
|
broadcast(`✅ Found ${result.results.length} results`, 'success', {
|
||||||
count: result.results.length,
|
count: result.results.length,
|
||||||
});
|
});
|
||||||
const cost=[];
|
|
||||||
cost.push({type:'exa_search', amount: result.costDollars.total});
|
const outputTokens = countTokens(JSON.stringify(result.results));
|
||||||
|
cost.push({
|
||||||
|
type:'exa_search',
|
||||||
|
amount: result.costDollars.total,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: outputTokens
|
||||||
|
});
|
||||||
|
|
||||||
const extractedContent = extractContent(result);
|
const extractedContent = extractContent(result);
|
||||||
|
|
||||||
@@ -105,13 +210,18 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
|||||||
question,
|
question,
|
||||||
});
|
});
|
||||||
summary = summaryResult.data;
|
summary = summaryResult.data;
|
||||||
cost.push({type:'openrouter_summarize', amount: summaryResult.cost});
|
cost.push({
|
||||||
|
type:'openrouter_rank',
|
||||||
|
amount: summaryResult.cost,
|
||||||
|
prompt_tokens: summaryResult.prompt_tokens,
|
||||||
|
completion_tokens: summaryResult.completion_tokens
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SearchServiceError('Failed to generate summary', 500, error);
|
throw new SearchServiceError('Failed to generate summary', 500, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summary?.sources) {
|
if (!summary?.sources) {
|
||||||
throw new SearchServiceError('Failed to generate summary');
|
throw new SearchServiceError('Failed to generate summary for query: ' + question + '. No sources returned:'+ JSON.stringify(summary, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast(`✅ Generated summaries for ${summary.sources.length} sources`, 'success', {
|
broadcast(`✅ Generated summaries for ${summary.sources.length} sources`, 'success', {
|
||||||
@@ -126,25 +236,35 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
|||||||
broadcast,
|
broadcast,
|
||||||
});
|
});
|
||||||
|
|
||||||
broadcast('🔧 Enhancing summaries with detailed content...', 'info');
|
|
||||||
enrichSourcesWithDetails(summary.sources, detailedContents);
|
enrichSourcesWithDetails(summary.sources, detailedContents);
|
||||||
for (const detailed of detailedContents) {
|
for (const detailed of detailedContents) {
|
||||||
if (detailed.cost) {
|
if (detailed.cost) {
|
||||||
cost.push({type:'exa_get_content', amount: detailed.cost});
|
const outputTokens = countTokens(JSON.stringify(detailed.content));
|
||||||
|
cost.push({
|
||||||
|
type:'exa_get_content',
|
||||||
|
amount: detailed.cost,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: outputTokens
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast('📊 Generating final summary...', 'info');
|
broadcast('📊 Generating final summary...', 'info');
|
||||||
|
|
||||||
let finalSummary;
|
|
||||||
try {
|
try {
|
||||||
const finalSummaryResult = await summarizeFinalAnswer({
|
const finalSummaryResult = await summarizeFinalAnswer({
|
||||||
openrouter,
|
openrouter,
|
||||||
text: formatSummarySources(summary.sources),
|
text: formatSummarySources(summary.sources,supplementaryResults),
|
||||||
question,
|
question,
|
||||||
});
|
});
|
||||||
finalSummary = finalSummaryResult.data;
|
finalSummary = finalSummaryResult.data;
|
||||||
cost.push({type:'openrouter_final_summary', amount: finalSummaryResult.cost});
|
cost.push({
|
||||||
|
type:'openrouter_final_summary',
|
||||||
|
amount: finalSummaryResult.cost,
|
||||||
|
prompt_tokens: finalSummaryResult.prompt_tokens,
|
||||||
|
completion_tokens: finalSummaryResult.completion_tokens
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SearchServiceError('Failed to generate final summary', 500, error);
|
throw new SearchServiceError('Failed to generate final summary', 500, error);
|
||||||
}
|
}
|
||||||
@@ -158,6 +278,9 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
|||||||
sourcesCount: finalSummary.mostRelevantSources?.length || 0,
|
sourcesCount: finalSummary.mostRelevantSources?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach cost breakdown to the final summary
|
||||||
|
finalSummary.costBreakdown = buildCostBreakdown(cost);
|
||||||
|
|
||||||
return finalSummary;
|
return finalSummary;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export function renderHTML(html) {
|
|||||||
// Handle <div>content</div> - block with newline
|
// Handle <div>content</div> - block with newline
|
||||||
text = text.replace(/<div>([\s\S]*?)<\/div>/gi, (match, content) => `${content.trim()}\n\n`);
|
text = text.replace(/<div>([\s\S]*?)<\/div>/gi, (match, content) => `${content.trim()}\n\n`);
|
||||||
|
|
||||||
|
// Handle <pre>content</pre> - preformatted text with monospace styling
|
||||||
|
text = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (match, content) => {
|
||||||
|
// Preserve internal whitespace and newlines, apply dimmed styling for code-like appearance
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const formattedLines = lines.map(line => chalk.dim(line));
|
||||||
|
return '\n' + formattedLines.join('\n') + '\n\n';
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up any remaining HTML tags that weren't processed
|
// Clean up any remaining HTML tags that weren't processed
|
||||||
text = text.replace(/<[^>]+>/g, '');
|
text = text.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user