variety of changes: custom system prompt, some bugs, dev qol
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
name: CI Test Guard
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-code:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Set up Node.js (For JS Validation)
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Python Linters
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install flake8
|
||||||
|
|
||||||
|
- name: Python Syntax Check
|
||||||
|
run: python -m py_compile ai_answers.py
|
||||||
|
|
||||||
|
- name: Python Undefined Variable Check
|
||||||
|
run: flake8 ai_answers.py --select=E9,F63,F7,F82 --show-source
|
||||||
|
|
||||||
|
- name: JavaScript Extraction & Syntax Check
|
||||||
|
run: |
|
||||||
|
python -c '
|
||||||
|
import re, sys
|
||||||
|
with open("ai_answers.py", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
match = re.search(r"FRONTEND_JS_TEMPLATE\s*=\s*r\"\"\"(.*?)\"\"\"", content, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
print("Could not find FRONTEND_JS_TEMPLATE")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
js_code = match.group(1)
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
"__IS_INTERACTIVE__": "true",
|
||||||
|
"__JS_Q__": "\"dummy_query\"",
|
||||||
|
"__JS_LANG__": "\"en\"",
|
||||||
|
"__JS_URLS__": "[]",
|
||||||
|
"__B64_CONTEXT__": "\"YmFzZTY0\"",
|
||||||
|
"__TK__": "\"dummy_token\"",
|
||||||
|
"__SCRIPT_ROOT__": "\"/searxng\"",
|
||||||
|
"__CITATION_HELPER_JS__": "/* citation helper */",
|
||||||
|
"__INTERACTIVE_JS_INIT__": "/* init */",
|
||||||
|
"__STREAM_FN_SIG__": "async function startStream(overrideQ = null, prevAnswer = null, auxContext = null)",
|
||||||
|
"__STREAM_Q__": "\"dummy_q\"",
|
||||||
|
"__STREAM_BODY__": "",
|
||||||
|
"__INTERACTIVE_JS_COMPLETE__": "/* complete */"
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val in replacements.items():
|
||||||
|
js_code = js_code.replace(key, val)
|
||||||
|
|
||||||
|
with open("frontend_test.js", "w", encoding="utf-8") as f:
|
||||||
|
f.write(js_code)
|
||||||
|
'
|
||||||
|
|
||||||
|
node --check frontend_test.js
|
||||||
@@ -38,6 +38,7 @@ Configure via the environment variables:
|
|||||||
|
|
||||||
- `LLM_MODEL`: Model identifier. Defaults vary. Recommended: 10-30B dense or 5-15B MoE activated.
|
- `LLM_MODEL`: Model identifier. Defaults vary. Recommended: 10-30B dense or 5-15B MoE activated.
|
||||||
- `LLM_URL`: Overrides endpoint URL for any provider preset.
|
- `LLM_URL`: Overrides endpoint URL for any provider preset.
|
||||||
|
- `LLM_SYSTEM_PROMPT`: Overrides some of the system prompt. Default `You are a direct, citation-accurate search synthesis engine.`.
|
||||||
- `LLM_MAX_TOKENS`: Default `500`.
|
- `LLM_MAX_TOKENS`: Default `500`.
|
||||||
- `LLM_TEMPERATURE`: Default `0.2`.
|
- `LLM_TEMPERATURE`: Default `0.2`.
|
||||||
- `LLM_CONTEXT_DEEP_COUNT`: results as context with full snippets. Default `5`.
|
- `LLM_CONTEXT_DEEP_COUNT`: results as context with full snippets. Default `5`.
|
||||||
@@ -105,6 +106,5 @@ LLM_MODEL=meta-llama/Meta-Llama-3-8B-Instruct
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install flask flask-babel
|
pip install flask flask-babel
|
||||||
python tests/demo.py # Interactive demo at localhost:5000
|
python tests/demo.py # UI demo at localhost:5000
|
||||||
python tests/test.py # One-shot test suite
|
|
||||||
```
|
```
|
||||||
|
|||||||
+466
-288
File diff suppressed because it is too large
Load Diff
-383
@@ -1,383 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
self.infoboxes = []
|
|
||||||
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)
|
|
||||||
Reference in New Issue
Block a user