feats: native searxng networking, code composition, ux polish, follow up querying via internals, config var clarity, readme

This commit is contained in:
cra88y/pc
2026-01-20 21:35:43 -06:00
parent 48accb6a0f
commit 9c5b016690
7 changed files with 1526 additions and 829 deletions
+1
View File
@@ -5,3 +5,4 @@ venv/
.env .env
.idea/ .idea/
.vscode/ .vscode/
.agent/
+24 -14
View File
@@ -9,6 +9,10 @@ Features:
- clickable inline citations - clickable inline citations
- interactive mode to continue summary, ask follow ups, copy, or regenerate - interactive mode to continue summary, ask follow ups, copy, or regenerate
- simple response mode with no extras - simple response mode with no extras
- internally called low-latency RAG for follow ups (bypasses http loopback)
- native network integration via `searx.network` (respects proxy/SSL settings)
- stateless conversation persistence/sharability via URL
- provider detection based on URL
## Installation ## Installation
@@ -23,26 +27,32 @@ plugins:
## Configuration ## Configuration
Set the following environment variables: Configure via the environment variables:
### Required ### Required
- `LLM_PROVIDER`: openrouter, openai, ollama, localai, lmstudio, gemini, azure, or huggingface - `LLM_PROVIDER`: openrouter, openai, ollama, localai, lmstudio, gemini, azure, or huggingface
- `LLM_KEY`: Your API key - `LLM_KEY`: Provider API key (optional for local providers: ollama, localai, lmstudio)
### Optional ### Optional
- `LLM_MODEL`: Model identifier. Defaults vary by provider. - `LLM_MODEL`: Model identifier. Defaults vary. Recommended: 10-30B dense or 5-15B MoE activated.
- `LLM_URL`: Custom endpoint URL. Overrides provider preset. - `LLM_URL`: Overrides endpoint URL for any provider preset.
- `LLM_MAX_TOKENS`: Defaults to `500`. - `LLM_MAX_TOKENS`: Default `500`.
- `LLM_TEMPERATURE`: Defaults to `0.2`. - `LLM_TEMPERATURE`: Default `0.2`.
- `LLM_CONTEXT_COUNT`: Search results to include. Defaults to `5`. - `LLM_CONTEXT_DEEP_COUNT`: results as context with full snippets. Default `5`.
- `LLM_TABS`: Comma-separated tab whitelist. Defaults to general,science,it,news. - `LLM_CONTEXT_SHALLOW_COUNT`: Results with headlines only (additional breadth). Default `15`.
- `LLM_STYLE`: UI mode. Set to "interactive" for interactive controls (copy, regenerate, follow up, continue). Defaults to "simple". - `LLM_TABS`: Tab whitelist, comma delimiter. Default `general,science,it,news`.
- `LLM_INTERACTIVE`: UI mode. Default is `true` (interactive: copy, regenerate, follow up). Set to `false` for simple response only mode.
## How It Works ## How It Works
1 user initial search
After search completes, the plugin extracts top search results as context. A client-side script calls the stream endpoint with a signed token. The LLM response streams back token by token. 2 results return server side
3 `post_search` plugin hook entry
4 token optimized context extracted
5 inject the ui/logic "shell" into standard results answer object
6 client side script calls custom endpoint with signed token
7 LLM response streams back token by token
## Examples ## Examples
@@ -92,7 +102,7 @@ LLM_MODEL=meta-llama/Meta-Llama-3-8B-Instruct
## Development ## Development
```bash ```bash
pip install flask flask-babel python-dotenv pip install flask flask-babel
python demo.py # Interactive test server on localhost:5000 python tests/demo.py # Interactive demo at localhost:5000
python test.py # One-shot test suite python tests/test.py # One-shot test suite
``` ```
+878 -476
View File
File diff suppressed because it is too large Load Diff
-153
View File
@@ -1,153 +0,0 @@
"""
AI Answers Plugin - Interactive Demo Server
Simulates SearXNG environment for local development and testing.
Usage: python demo.py
Then visit: http://localhost:5000/?q=your+query+here
Requires: pip install flask flask-babel python-dotenv
"""
import sys
import os
import logging
from types import ModuleType
from flask import Flask, request
from dotenv import load_dotenv
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
load_dotenv()
os.environ.setdefault('LLM_STYLE', 'interactive')
# Mock SearXNG modules
searx = ModuleType("searx")
searx_plugins = ModuleType("searx.plugins")
searx_results = ModuleType("searx.result_types")
class MockPlugin:
def __init__(self, cfg):
self.active = getattr(cfg, 'active', True)
class MockPluginInfo:
def __init__(self, **kwargs):
self.meta = kwargs
class MockEngineResults:
def __init__(self):
self.types = ModuleType("types")
self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "")
self._results = []
def add(self, res):
self._results.append(res)
searx_plugins.Plugin = MockPlugin
searx_plugins.PluginInfo = MockPluginInfo
searx_results.EngineResults = MockEngineResults
sys.modules["searx"] = searx
sys.modules["searx.plugins"] = searx_plugins
sys.modules["searx.result_types"] = searx_results
from ai_answers import SXNGPlugin
from flask_babel import Babel
app = Flask(__name__)
babel = Babel(app)
class MockConfig:
active = True
plugin = SXNGPlugin(MockConfig())
plugin.init(app)
@app.route("/")
def index():
query = request.args.get("q", "why is the sky blue")
class MockSearchQuery:
pageno = 1
lang = 'en'
categories = ['general']
MockSearchQuery.query = query
class MockSearch:
search_query = MockSearchQuery()
class MockResultContainer:
def __init__(self):
self.answers = set()
def get_ordered_results(self):
if 'quantum' in query.lower():
return [
{"title": "IBM Quantum", "content": "Quantum computers rely on qubits, which can represent 0, 1, or both via superposition. They solve complex problems faster.", "url": "https://www.ibm.com/quantum", "publishedDate": "2026-01-15"},
{"title": "Nature Physics", "content": "Entanglement allows qubits to be correlated instantly across distances. This is key for quantum cryptography and teleportation.", "url": "https://nature.com/articles/quantum", "publishedDate": "2026-01-10"},
{"title": "Wikipedia", "content": "Quantum computing uses quantum mechanics. Major applications include drug discovery and materials science.", "url": "https://en.wikipedia.org/wiki/Quantum_computing", "publishedDate": "2025-12-01"}
]
return [
{"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering of sunlight.", "url": "https://en.wikipedia.org/wiki/Rayleigh_scattering", "publishedDate": "2026-01-15"},
{"title": "NASA Science", "content": "Shorter blue wavelengths scatter more than longer red wavelengths.", "url": "https://science.nasa.gov/blue-sky", "publishedDate": "2026-01-10"},
{"title": "Physics Today", "content": "The atmosphere acts as a filter, scattering blue light in all directions.", "url": "https://physicstoday.org/atmosphere", "publishedDate": "2026-01-01"}
]
result_container = MockResultContainer()
search = MockSearch()
plugin.post_search(None, search)
injection_html = ""
if search.result_container.answers:
injection_html = list(search.result_container.answers)[0]
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Answers Demo</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 2rem;
max-width: 800px;
margin: 0 auto;
background: #2e3440;
color: #eceff4;
}}
:root {{
--color-result-border: #3b4252;
--color-result-description: #d8dee9;
--color-base-font: #88c0d0;
--color-result-link: #81a1c1;
}}
h1 {{ color: #88c0d0; }}
.meta {{ color: #81a1c1; font-size: 0.9rem; }}
hr {{ border-color: #4c566a; }}
a {{ color: #88c0d0; }}
</style>
</head>
<body>
<div style="margin-top: 2rem;"></div>
<p class="meta">Provider: <strong>{plugin.provider or 'Not configured'}</strong> | Model: <strong>{plugin.model or 'N/A'}</strong></p>
<p>Query: <strong>{query}</strong></p>
<hr>
{injection_html if injection_html else '<p style="color:#f66;">Plugin inactive. Set LLM_PROVIDER and LLM_KEY in .env</p>'}
<hr>
<p class="meta">Try: <a href="/?q=what+is+quantum+computing">/?q=what+is+quantum+computing</a></p>
</body>
</html>
"""
if __name__ == "__main__":
print()
print("=" * 50)
print(" AI Answers Plugin - Demo Server")
print("=" * 50)
print(f" Provider: {plugin.provider or 'NOT SET'}")
print(f" Model: {plugin.model or 'N/A'}")
print(f" Style: {plugin.style}")
print(f" Status: {'Active' if plugin.api_key else 'Inactive (no LLM_KEY)'}")
print("=" * 50)
print(" http://localhost:5000/?q=your+query+here")
print("=" * 50)
print()
app.run(debug=True, port=5000)
-186
View File
@@ -1,186 +0,0 @@
"""
AI Answers Plugin - One-Shot Test
Comprehensive test that outputs everything: config, injection, LLM response.
Usage: python test.py
Requires: pip install flask flask-babel python-dotenv
"""
import sys
import os
import re
import time
import logging
from types import ModuleType
from dotenv import load_dotenv
load_dotenv()
# Suppress Flask noise during test
logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Mock SearXNG modules
searx = ModuleType("searx")
searx_plugins = ModuleType("searx.plugins")
searx_results = ModuleType("searx.result_types")
class MockPlugin:
def __init__(self, cfg):
self.active = getattr(cfg, 'active', True)
class MockPluginInfo:
def __init__(self, **kwargs):
self.meta = kwargs
class MockEngineResults:
def __init__(self):
self.types = ModuleType("types")
self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "")
self._results = []
def add(self, res):
self._results.append(res)
searx_plugins.Plugin = MockPlugin
searx_plugins.PluginInfo = MockPluginInfo
searx_results.EngineResults = MockEngineResults
sys.modules["searx"] = searx
sys.modules["searx.plugins"] = searx_plugins
sys.modules["searx.result_types"] = searx_results
from flask import Flask
from flask_babel import Babel
from ai_answers import SXNGPlugin
def run_tests():
print()
print("=" * 60)
print(" AI Answers Plugin - Comprehensive Test")
print("=" * 60)
# === CONFIG TEST ===
print("\n[1/4] Configuration")
print("-" * 40)
app = Flask(__name__)
Babel(app)
class MockConfig:
active = True
plugin = SXNGPlugin(MockConfig())
plugin.init(app)
print(f" Provider: {plugin.provider or 'NOT SET'}")
print(f" Model: {plugin.model or 'N/A'}")
print(f" API Key: {'[OK]' if plugin.api_key else '[MISSING]'}")
print(f" Max Tokens: {getattr(plugin, 'max_tokens', 'N/A')}")
print(f" Temperature: {getattr(plugin, 'temperature', 'N/A')}")
print(f" Context Count: {getattr(plugin, 'context_count', 'N/A')}")
print(f" Allowed Tabs: {getattr(plugin, 'allowed_tabs', 'N/A')}")
if not plugin.api_key:
print("\n" + "=" * 60)
print(" SKIPPED: No LLM_KEY configured")
print(" Set LLM_PROVIDER and LLM_KEY in .env to run full test")
print("=" * 60)
return False
# === INJECTION TEST ===
print("\n[2/4] HTML Injection")
print("-" * 40)
class MockSearchQuery:
pageno = 1
query = "why is the sky blue"
lang = 'en'
categories = ['general']
class MockSearch:
search_query = MockSearchQuery()
class MockResultContainer:
def __init__(self):
self.answers = set()
def get_ordered_results(self):
return [
{"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering.", "url": "https://example.com/1", "publishedDate": "2026-01-15"},
{"title": "NASA", "content": "Blue wavelengths scatter more than red.", "url": "https://example.com/2", "publishedDate": "2026-01-10"},
]
result_container = MockResultContainer()
search = MockSearch()
plugin.post_search(None, search)
if not search.result_container.answers:
print(" FAIL: No HTML injected")
return False
html = str(list(search.result_container.answers)[0])
has_box = 'id="sxng-stream-box"' in html
has_endpoint = '/ai-stream' in html
token_match = re.search(r'const tk = "(.*?)";', html)
has_token = bool(token_match)
print(f" Stream box: {'[OK]' if has_box else '[FAIL]'}")
print(f" Endpoint ref: {'[OK]' if has_endpoint else '[FAIL]'}")
print(f" Auth token: {'[OK]' if has_token else '[FAIL]'}")
print(f" HTML size: {len(html):,} bytes")
if not (has_box and has_endpoint and has_token):
print(" FAIL: Missing required elements")
return False
# === STREAM ENDPOINT TEST ===
print("\n[3/4] Stream Endpoint")
print("-" * 40)
with app.test_client() as client:
payload = {
"q": "why is the sky blue",
"context": "[1] Wikipedia: The sky appears blue due to Rayleigh scattering.",
"lang": "en",
"tk": token_match.group(1)
}
start = time.time()
response = client.post('/ai-stream', json=payload)
elapsed = time.time() - start
print(f" Status: {response.status_code}")
print(f" Time: {elapsed:.2f}s")
if response.status_code != 200:
print(f" FAIL: Expected 200, got {response.status_code}")
return False
# === LLM RESPONSE TEST ===
print("\n[4/4] LLM Response")
print("-" * 40)
data = response.data.decode('utf-8')
print(f" Bytes: {len(data):,}")
print(f" Words: ~{len(data.split())}")
if len(data) < 10:
print(" FAIL: Response too short (API error?)")
return False
print("\n --- Response Preview ---")
preview = data[:500] + ("..." if len(data) > 500 else "")
for line in preview.split('\n'):
print(f" {line}")
print(" --- End Preview ---")
# === SUMMARY ===
print("\n" + "=" * 60)
print(" ALL TESTS PASSED")
print("=" * 60)
return True
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)
+241
View File
@@ -0,0 +1,241 @@
import sys
import os
import logging
from types import ModuleType
from flask import Flask, request
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# os.environ.setdefault('LLM_STYLE', 'interactive') # Removed to let plugin config decide defaults
# SearXNG module mocks
searx = ModuleType("searx")
searx_plugins = ModuleType("searx.plugins")
searx_results = ModuleType("searx.result_types")
class MockPlugin:
def __init__(self, cfg):
self.active = getattr(cfg, 'active', True)
class MockPluginInfo:
def __init__(self, **kwargs):
self.meta = kwargs
class MockEngineResults:
def __init__(self):
self.types = ModuleType("types")
self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "")
self._results = []
def add(self, res):
self._results.append(res)
searx_plugins.Plugin = MockPlugin
searx_plugins.PluginInfo = MockPluginInfo
searx_results.EngineResults = MockEngineResults
sys.modules["searx"] = searx
sys.modules["searx.plugins"] = searx_plugins
sys.modules["searx.result_types"] = searx_results
# Network module mock
searx_network = ModuleType("searx.network")
def mock_network_call(method, url, **kwargs):
import http.client, ssl, json
from urllib.parse import urlparse
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme=='https' else 80)
target = f"{parsed.hostname}:{port}"
if parsed.scheme == 'https':
conn = http.client.HTTPSConnection(target, timeout=30, context=ssl.create_default_context())
else:
conn = http.client.HTTPConnection(target, timeout=30)
headers = kwargs.get('headers', {})
body = None
if kwargs.get('json'):
body = json.dumps(kwargs['json'])
elif kwargs.get('data'):
body = kwargs['data']
path = parsed.path
if parsed.query:
path += f"?{parsed.query}"
if kwargs.get('params'):
from urllib.parse import urlencode
query_str = urlencode(kwargs['params'])
if '?' in path:
path += f"&{query_str}"
else:
path += f"?{query_str}"
conn.request(method, path, body=body, headers=headers)
return conn.getresponse()
def mock_stream(method, url, **kwargs):
res = mock_network_call(method, url, **kwargs)
class MockResponse:
def __init__(self, r):
self.status_code = r.status
self.text = "Mock Response" # Stub
self._r = r
def generator():
while True:
chunk = res.read(128)
if not chunk: break
yield chunk
return MockResponse(res), generator()
def mock_get(url, **kwargs):
import json
res = mock_network_call('GET', url, **kwargs)
class MockResponse:
def __init__(self, r):
self.status_code = r.status
self._content = r.read()
self.text = self._content.decode('utf-8')
def json(self):
return json.loads(self.text)
return MockResponse(res)
searx_network.stream = mock_stream
searx_network.get = mock_get
sys.modules["searx.network"] = searx_network
from ai_answers import SXNGPlugin
from flask_babel import Babel
app = Flask(__name__)
babel = Babel(app)
class MockConfig:
active = True
plugin = SXNGPlugin(MockConfig())
plugin.init(app)
@app.route("/search")
def mock_search():
query = request.args.get("q", "")
format_type = request.args.get("format", "html")
if format_type != "json":
return "Demo only supports JSON format", 400
results = [
{"title": f"Result 1 for: {query}", "content": f"This is simulated content about {query}. It contains relevant information.", "url": f"https://example.com/1/{query.replace(' ', '-')}", "publishedDate": "2026-01-18"},
{"title": f"Result 2 for: {query}", "content": f"Additional information regarding {query}. More context and details.", "url": f"https://example.com/2/{query.replace(' ', '-')}", "publishedDate": "2026-01-17"},
{"title": f"Result 3 for: {query}", "content": f"Further reading on {query}. Expert analysis.", "url": f"https://example.com/3/{query.replace(' ', '-')}", "publishedDate": "2026-01-16"},
]
return {
"results": results,
"infoboxes": [],
"answers": [],
"suggestions": [f"{query} explained", f"{query} tutorial"]
}
@app.route("/")
def index():
query = request.args.get("q", "why is the sky blue")
class MockSearchQuery:
pageno = 1
lang = 'en'
categories = ['general']
MockSearchQuery.query = query
class MockSearch:
search_query = MockSearchQuery()
class MockResultContainer:
def __init__(self):
self.answers = set()
def get_ordered_results(self):
base_results = [
{"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering of sunlight. When sunlight enters the atmosphere, it collides with gas molecules and scatters in all directions. Blue light scatters more than other colors because it travels in shorter waves.", "url": "https://en.wikipedia.org/wiki/Rayleigh_scattering", "publishedDate": "2026-01-15"},
{"title": "NASA Science", "content": "Shorter blue wavelengths scatter more than longer red wavelengths. This phenomenon, discovered by Lord Rayleigh in the 1870s, explains why we see a blue sky during the day.", "url": "https://science.nasa.gov/blue-sky", "publishedDate": "2026-01-10"},
{"title": "Physics Today", "content": "The atmosphere acts as a filter, scattering blue light in all directions while letting other colors pass through more directly.", "url": "https://physicstoday.org/atmosphere", "publishedDate": "2026-01-01"},
{"title": "Scientific American", "content": "At sunset, light travels through more atmosphere, scattering away the blue and leaving reds and oranges.", "url": "https://scientificamerican.com/sunset", "publishedDate": "2025-12-20"},
{"title": "National Geographic", "content": "Ocean color also results from light scattering and absorption by water molecules.", "url": "https://nationalgeographic.com/ocean-blue", "publishedDate": "2025-12-15"},
]
broad_results = [
{"title": "MIT OpenCourseWare: Atmospheric Physics", "content": "Course materials.", "url": "https://ocw.mit.edu/physics"},
{"title": "NOAA: Understanding the Atmosphere", "content": "Educational resource.", "url": "https://noaa.gov/atmosphere"},
{"title": "BBC Science: Why is the sky blue?", "content": "Explainer article.", "url": "https://bbc.com/science/sky"},
{"title": "Khan Academy: Light and Color", "content": "Video lesson.", "url": "https://khanacademy.org/light"},
{"title": "HowStuffWorks: Rayleigh Scattering", "content": "Detailed explanation.", "url": "https://howstuffworks.com/rayleigh"},
{"title": "Physics Stack Exchange: Sky color discussion", "content": "Q&A thread.", "url": "https://physics.stackexchange.com/sky"},
{"title": "Quora: Atmospheric optics explained", "content": "Community answers.", "url": "https://quora.com/atmosphere"},
]
if 'quantum' in query.lower():
return [
{"title": "IBM Quantum", "content": "Quantum computers rely on qubits, which can represent 0, 1, or both via superposition. They solve complex problems faster.", "url": "https://www.ibm.com/quantum", "publishedDate": "2026-01-15"},
{"title": "Nature Physics", "content": "Entanglement allows qubits to be correlated instantly across distances. This is key for quantum cryptography and teleportation.", "url": "https://nature.com/articles/quantum", "publishedDate": "2026-01-10"},
{"title": "Wikipedia", "content": "Quantum computing uses quantum mechanics. Major applications include drug discovery and materials science.", "url": "https://en.wikipedia.org/wiki/Quantum_computing", "publishedDate": "2025-12-01"}
] + broad_results
return base_results + broad_results
result_container = MockResultContainer()
search = MockSearch()
plugin.post_search(None, search)
injection_html = ""
if search.result_container.answers:
injection_html = list(search.result_container.answers)[0]
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Answers Demo</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 2rem;
max-width: 800px;
margin: 0 auto;
background: #2e3440;
color: #eceff4;
}}
:root {{
--color-result-border: #3b4252;
--color-result-description: #d8dee9;
--color-base-font: #88c0d0;
--color-result-link: #81a1c1;
}}
h1 {{ color: #88c0d0; }}
.meta {{ color: #81a1c1; font-size: 0.9rem; }}
hr {{ border-color: #4c566a; }}
a {{ color: #88c0d0; }}
</style>
</head>
<body>
<div style="margin-top: 2rem;"></div>
<p class="meta">Provider: <strong>{plugin.provider or 'Not configured'}</strong> | Model: <strong>{plugin.model or 'N/A'}</strong></p>
<p>Query: <strong>{query}</strong></p>
<hr>
{injection_html if injection_html else '<p style="color:#f66;">Plugin inactive. Set LLM_PROVIDER and LLM_KEY in .env</p>'}
<hr>
<p class="meta">Try: <a href="/?q=what+is+quantum+computing">/?q=what+is+quantum+computing</a></p>
</body>
</html>
"""
if __name__ == "__main__":
print("AI Answers - Demo\n")
print(f" Provider: {plugin.provider or 'NOT SET'}")
print(f" Model: {plugin.model or 'N/A'}")
print(f" Mode: {'interactive' if plugin.interactive else 'simple'}")
print(f" Status: {'active' if plugin.api_key else 'inactive (no LLM_KEY)'}")
print(f"\n http://localhost:5000/?q=why+is+the+sky+blue\n")
app.run(debug=False, port=5000)
+382
View File
@@ -0,0 +1,382 @@
"""
AI Answers Plugin - Comprehensive Test
Test suite that verifies both 'interactive' and 'simple' modes,
checks configuration, and validates LLM integration.
Usage: python test.py
Requires: pip install flask flask-babel python-dotenv
"""
import os
import sys
# Add parent directory to path to find ai_answers.py
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import re
import time
import logging
import subprocess
import tempfile
from types import ModuleType
import warnings
warnings.filterwarnings("ignore", category=SyntaxWarning)
# Suppress Flask noise during test
logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.basicConfig(level=logging.INFO, format='%(message)s')
# --- MOCKS START ---
# SearXNG module mocks
searx = ModuleType("searx")
searx_plugins = ModuleType("searx.plugins")
searx_results = ModuleType("searx.result_types")
class MockPlugin:
def __init__(self, cfg):
self.active = getattr(cfg, 'active', True)
class MockPluginInfo:
def __init__(self, **kwargs):
self.meta = kwargs
class MockEngineResults:
def __init__(self):
self.types = ModuleType("types")
self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "")
self._results = []
def add(self, res):
self._results.append(res)
def get_ordered_results(self):
return self._results
searx_plugins.Plugin = MockPlugin
searx_plugins.PluginInfo = MockPluginInfo
searx_results.EngineResults = MockEngineResults
# Internal search API mocks
searx_search = ModuleType("searx.search")
searx_search_models = ModuleType("searx.search.models")
searx_query = ModuleType("searx.query")
searx_webadapter = ModuleType("searx.webadapter")
class MockSearchWithPlugins:
def __init__(self, search_query, request, user_plugins):
self.search_query = search_query
self.result_container = MockEngineResults()
# Add some mock results
self.result_container.add({"title": "Mock Aux Result", "url": "https://test.com", "content": "Test content", "publishedDate": "2026"})
# Add mock infoboxes/answers
self.result_container.infoboxes = [{"infobox": "Test Box", "content": "Box Content", "attributes": []}]
self.result_container.answers = set()
self.result_container.answers_list = ["Test Answer"] # Simulating raw answers list if needed
def search(self):
return self.result_container
class MockSearchQuery:
def __init__(self, query, engineref_list, **kwargs):
self.query = query
class MockRawTextQuery:
def __init__(self, query, disabled_engines):
self.query = query
def getQuery(self):
return self.query
searx_search.SearchWithPlugins = MockSearchWithPlugins
searx_search.models = searx_search_models
searx_search_models.SearchQuery = MockSearchQuery
searx_query.RawTextQuery = MockRawTextQuery
searx_webadapter.get_engineref_from_category_list = lambda cats, disabled: []
sys.modules["searx.search"] = searx_search
sys.modules["searx.search.models"] = searx_search_models
sys.modules["searx.query"] = searx_query
sys.modules["searx.webadapter"] = searx_webadapter
# Network module mock
searx_network = ModuleType("searx.network")
def mock_network_call(method, url, **kwargs):
import http.client, ssl, json
from urllib.parse import urlparse
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme=='https' else 80)
target = f"{parsed.hostname}:{port}"
if parsed.scheme == 'https':
conn = http.client.HTTPSConnection(target, timeout=30, context=ssl.create_default_context())
else:
conn = http.client.HTTPConnection(target, timeout=30)
headers = kwargs.get('headers', {})
body = None
if kwargs.get('json'):
body = json.dumps(kwargs['json'])
elif kwargs.get('data'):
body = kwargs['data']
path = parsed.path
if parsed.query:
path += f"?{parsed.query}"
if kwargs.get('params'):
from urllib.parse import urlencode
query_str = urlencode(kwargs['params'])
if '?' in path:
path += f"&{query_str}"
else:
path += f"?{query_str}"
conn.request(method, path, body=body, headers=headers)
print(f" [DEBUG] Network Call: {method} {target}{path}")
print(f" [DEBUG] Headers: {headers}")
# print(f" [DEBUG] Body: {body}")
return conn.getresponse()
def mock_stream(method, url, **kwargs):
res = mock_network_call(method, url, **kwargs)
class MockResponse:
def __init__(self, r):
self.status_code = r.status
self.text = "Mock Response" # Stub
self._r = r
def generator():
while True:
chunk = res.read(128)
if not chunk: break
yield chunk
return MockResponse(res), generator()
def mock_get(url, **kwargs):
import json
res = mock_network_call('GET', url, **kwargs)
class MockResponse:
def __init__(self, r):
self.status_code = r.status
self._content = r.read()
self.text = self._content.decode('utf-8')
def json(self):
return json.loads(self.text)
return MockResponse(res)
searx_network.stream = mock_stream
searx_network.get = mock_get
sys.modules["searx.network"] = searx_network
sys.modules["searx"] = searx
sys.modules["searx.plugins"] = searx_plugins
sys.modules["searx.result_types"] = searx_results
# --- MOCKS END ---
from flask import Flask
from flask_babel import Babel
from ai_answers import SXNGPlugin
def check_js_syntax(js_code):
"""Returns (valid, error_msg)"""
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8') as f:
f.write(js_code)
temp_path = f.name
result = subprocess.run(
['node', '--check', temp_path],
capture_output=True,
text=True,
timeout=5
)
os.unlink(temp_path)
if result.returncode == 0:
return True, None
else:
return False, result.stderr.strip()
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
return True, f"[SKIP] {e}" # Skip if node not found
def run_tests():
print("AI Answers - Test Suite\n")
print("[Syntax]")
import py_compile
try:
target_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ai_answers.py')
py_compile.compile(target_file, doraise=True)
print(" Python: OK")
except py_compile.PyCompileError as e:
print(f" Syntax: [FAIL] {e}")
return False
modes = ['interactive', 'simple']
for mode in modes:
app = Flask(__name__)
Babel(app)
# Set LLM_INTERACTIVE based on mode
os.environ['LLM_INTERACTIVE'] = 'true' if mode == 'interactive' else 'false'
# Override env var for this iteration
# os.environ['LLM_STYLE'] = mode # Legacy
os.environ['LLM_INTERACTIVE'] = 'true' if mode == 'interactive' else 'false'
class MockConfig:
active = True
# Re-init plugin with new env var in effect
plugin = SXNGPlugin(MockConfig())
plugin.init(app)
if mode == 'interactive':
print(f"\n[Config]")
print(f" Provider: {plugin.provider or 'NOT SET'}")
print(f" API Key: {'OK' if plugin.api_key else 'MISSING'}")
# Construct Search
class MockSearchQuery:
pageno = 1
query = "test query"
lang = 'en'
categories = ['general']
class MockSearch:
search_query = MockSearchQuery()
class MockResultContainer:
def __init__(self):
self.answers = set()
def get_ordered_results(self):
return [
{"title": "T1", "content": "C1", "url": "https://a.com/1", "publishedDate": "2026-01-15"},
{"title": "T2", "content": "C2", "url": "https://a.com/2", "publishedDate": "2026-01-10"},
]
result_container = MockResultContainer()
search = MockSearch()
plugin.post_search(None, search)
if not search.result_container.answers:
print(" FAIL: No HTML injected")
return False
html = str(list(search.result_container.answers)[0])
# Mode-specific basic validations
has_box = 'id="sxng-stream-box"' in html
has_footer = 'id="sxng-footer"' in html
if mode == 'interactive':
if has_box and has_footer:
print("\n[Render: interactive]")
print(" UI: OK")
else:
print(f" FAIL: Box={has_box}, Footer={has_footer}")
return False
else:
if has_box and not has_footer:
print("\n[Render: simple]")
print(" UI: OK")
else:
print(f" FAIL: Box={has_box}, Footer={has_footer}")
return False
# JS Verification
js_match = re.search(r'<script>(.*?)</script>', html, re.DOTALL)
if not js_match:
print(" FAIL: No script tag found")
return False
js_code = js_match.group(1).strip()
valid, err = check_js_syntax(js_code)
if valid:
print(" JS: OK")
else:
print(" JS Syntax: [FAIL]")
print(f" Error: {err.splitlines()[0][:80]}...")
return False
print(f" Size: {len(html):,} bytes")
# Verify Critical Fix: Function Signature
# simple mode caused reference error if signature wasn't unified
if 'async function startStream(overrideQ = null, prevAnswer = null, auxContext = null)' in js_code:
print(" Signature: OK")
else:
print(" Signature Fix: [FAIL] Unified startStream signature MISSING")
# Not fatal for interactive per se, but fatal if consistent code is desired
# For simple mode it IS fatal in runtime.
if mode == 'simple': return False
# ---------------------------------------------------------
# GLOBAL ENDPOINT / INTEGRATION TESTS (Using last plugin init)
# ---------------------------------------------------------
if not plugin.api_key:
print("\n[Skip integration: no LLM_KEY]")
return True
print(f"\n[Stream]")
print(f" Provider: {plugin.provider}")
print(f" Model: {plugin.model}")
# Needs a token from the last run to pass auth
token_match = re.search(r'tk_init = "(.*?)";', html)
if not token_match:
print(" FAIL: Could not extract token for stream test")
return False
with app.test_client() as client:
payload = {
"q": "why is the sky blue",
"context": "[1] Wikipedia: The sky appears blue.",
"lang": "en",
"tk": token_match.group(1)
}
start = time.time()
response = client.post('/ai-stream', json=payload)
elapsed = time.time() - start
print(f" Status: {response.status_code}")
print(f" Time: {elapsed:.2f}s")
if response.status_code != 200:
print(f" FAIL: Expected 200, got {response.status_code}")
return False
data = response.data.decode('utf-8')
if len(data) < 5:
print(" FAIL: Empty or too short response")
return False
print(" Result: OK")
print("\n[Aux Search]")
with app.test_client() as client:
aux_response = client.post('/ai-auxiliary-search', json={'query': 'test'})
if aux_response.status_code == 200 and 'results' in aux_response.get_json():
print(" Result: OK")
else:
print(" Aux Endpoint: [FAIL]")
print("\nPASS")
return True
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)