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
+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)