Enter a query above and click Search to test the plugin.
""" dev.py — AI Answers Plugin Development Server Provides a full-featured local dev environment for ollama_answers.py. Features: - Mock SearXNG environment so the plugin loads without a real SearXNG install - Optional live SearXNG backend (set SEARXNG_URL to proxy real searches) - Debug console with request/response logging - Plugin API explorer (all routes: /ai-stream, /ai-status, /ai-models, /ai-conversation, /ai-auxiliary-search) - Dark UI matching SearXNG's default theme with real CSS vars - Config panel: LLM endpoint, model, token limits, temperature - Live query testing with streaming output in the browser Usage: cd dev/ python dev.py Or with a live SearXNG backend: SEARXNG_URL=http://localhost:8080 python dev.py Environment variables (all optional): LLM_URL Ollama endpoint (default: http://localhost:11434/v1/chat/completions) LLM_MODEL Model name (default: qwen3.5:9b) LLM_MAX_TOKENS Max tokens (default: 200) LLM_TEMPERATURE Temperature (default: 0.2) SEARXNG_URL Live SearXNG URL (default: None, uses mock results) DEV_PORT Port to listen on (default: 5000) DEV_HOST Host to bind to (default: 127.0.0.1) """ import sys import os import json import time import logging import hashlib import secrets import urllib.request import urllib.parse from types import ModuleType # ── path setup ────────────────────────────────────────────────────────────── sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ── .env loading ───────────────────────────────────────────────────────────── try: from dotenv import load_dotenv # look for .env in the project root (one level up from dev/) _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') if os.path.exists(_env_path): load_dotenv(_env_path) print(f" Loaded .env from {_env_path}") except ImportError: pass # python-dotenv not installed, ignore # ── logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(name)s: %(message)s', datefmt='%H:%M:%S' ) logger = logging.getLogger('dev') # ── env defaults ───────────────────────────────────────────────────────────── os.environ.setdefault('LLM_URL', 'http://localhost:11434/v1/chat/completions') os.environ.setdefault('LLM_MODEL', 'qwen3.5:9b') SEARXNG_URL = os.getenv('SEARXNG_URL', '').rstrip('/') DEV_PORT = int(os.getenv('DEV_PORT', 5000)) DEV_HOST = os.getenv('DEV_HOST', '127.0.0.1') # ── SearXNG module mocks ───────────────────────────────────────────────────── searx_mod = ModuleType("searx") searx_plugins_mod = ModuleType("searx.plugins") searx_results_mod = ModuleType("searx.result_types") searx_network_mod = ModuleType("searx.network") 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 *a, **kw: kw.get('answer', a[0] if a else "") self._results = [] def add(self, res): self._results.append(res) searx_plugins_mod.Plugin = _MockPlugin searx_plugins_mod.PluginInfo = _MockPluginInfo searx_results_mod.EngineResults = _MockEngineResults searx_mod.settings = {'server': {'secret_key': 'dev-secret-key'}} # network mock — forwards real HTTP so Ollama calls work import http.client, ssl as _ssl def _real_http(method, url, **kwargs): from urllib.parse import urlparse p = urlparse(url) port = p.port or (443 if p.scheme == 'https' else 80) path = p.path + (f'?{p.query}' if p.query else '') if p.scheme == 'https': conn = http.client.HTTPSConnection(p.hostname, port, timeout=30, context=_ssl.create_default_context()) else: conn = http.client.HTTPConnection(p.hostname, port, timeout=30) headers = kwargs.get('headers', {}) body = json.dumps(kwargs['json']).encode() if kwargs.get('json') else kwargs.get('data') if kwargs.get('params'): qs = urllib.parse.urlencode(kwargs['params']) path += ('&' if '?' in path else '?') + qs conn.request(method, path, body=body, headers=headers) return conn.getresponse() class _MockHTTPResponse: def __init__(self, r): self.status_code = r.status self._content = r.read() self.text = self._content.decode('utf-8', errors='replace') def json(self): return json.loads(self.text) def _mock_get(url, **kw): return _MockHTTPResponse(_real_http('GET', url, **kw)) def _mock_stream(method, url, **kw): r = _real_http(method, url, **kw) class _Resp: status_code = r.status text = "streaming" def _gen(): while True: chunk = r.read(256) if not chunk: break yield chunk return _Resp(), _gen() searx_network_mod.stream = _mock_stream searx_network_mod.get = _mock_get sys.modules['searx'] = searx_mod sys.modules['searx.plugins'] = searx_plugins_mod sys.modules['searx.result_types'] = searx_results_mod sys.modules['searx.network'] = searx_network_mod # ── log capture handler for intent / score extraction ──────────────────────── class _ScoreCaptureHandler(logging.Handler): """Captures plugin log messages so dev routes can extract intent/scores.""" def __init__(self): super().__init__() self.records = [] def emit(self, record): self.records.append(self.format(record)) def clear(self): self.records.clear() _score_handler = _ScoreCaptureHandler() _score_handler.setFormatter(logging.Formatter('%(message)s')) logging.getLogger('ollama_answers').addHandler(_score_handler) # ── load plugin ─────────────────────────────────────────────────────────────── from flask import Flask, request, redirect, jsonify, Response from flask_babel import Babel from ollama_answers import SXNGPlugin app = Flask(__name__) babel = Babel(app) class _MockCfg: active = True plugin = SXNGPlugin(_MockCfg()) plugin.init(app) # Override api_key from env if set env_key = os.getenv('LLM_API_KEY', '').strip() if env_key: plugin.api_key = env_key # Verify Valkey is reachable try: import valkey as _vk _vk_host = os.getenv('VALKEY_HOST', 'localhost') _vk_port = int(os.getenv('VALKEY_PORT', 6379)) _test_vk = _vk.Valkey(host=_vk_host, port=_vk_port, socket_connect_timeout=2) _test_vk.ping() print(f" Valkey: {_vk_host}:{_vk_port} ✓") except Exception as e: print(f""" ⚠ Valkey not reachable at {_vk_host}:{_vk_port} Streaming will not work without Valkey. Start one with Docker: docker run -d --name dev-valkey -p 6379:6379 valkey/valkey:9-alpine Or set VALKEY_HOST and VALKEY_PORT in your .env file. Error: {e} """) # ── request/response debug log storage ─────────────────────────────────────── _DEV_SESSION = secrets.token_hex(12) _dev_log = [] # list of {ts, method, path, status, ms, req_body, resp_body} MAX_LOG = 100 @app.before_request def _log_req(): request._dev_start = time.time() request._dev_body = request.get_data(as_text=True) @app.after_request def _log_resp(resp): if request.path.startswith('/static'): return resp ms = round((time.time() - getattr(request, '_dev_start', time.time())) * 1000, 1) entry = { 'ts': time.strftime('%H:%M:%S'), 'method': request.method, 'path': request.full_path.rstrip('?'), 'status': resp.status_code, 'ms': ms, 'req': getattr(request, '_dev_body', ''), 'resp': resp.get_data(as_text=True)[:2000], } _dev_log.insert(0, entry) if len(_dev_log) > MAX_LOG: _dev_log.pop() return resp # ── mock search results ─────────────────────────────────────────────────────── MOCK_RESULTS = { 'default': [ {"title": "Wikipedia — General Knowledge", "content": "A comprehensive reference covering the query topic in depth with cited sources.", "url": "https://en.wikipedia.org/wiki/Main_Page", "publishedDate": "2026-01-15"}, {"title": "NASA Science — Research Article", "content": "Peer-reviewed research providing scientific context and empirical data on the topic.", "url": "https://science.nasa.gov/", "publishedDate": "2026-01-10"}, {"title": "Scientific American — Analysis", "content": "In-depth analysis covering recent discoveries and expert commentary.", "url": "https://scientificamerican.com/", "publishedDate": "2026-01-01"}, {"title": "BBC Science — Overview", "content": "Accessible explainer covering the fundamentals and current understanding.", "url": "https://bbc.com/science", "publishedDate": "2025-12-20"}, {"title": "MIT OpenCourseWare — Educational Resource", "content": "Course materials and lecture notes providing structured educational content.", "url": "https://ocw.mit.edu/", "publishedDate": "2025-12-15"}, ] + [ {"title": f"Source {i} — Additional Context", "content": f"Supplementary information and supporting details from source {i}.", "url": f"https://example.com/source-{i}", "publishedDate": "2025-12-01"} for i in range(6, 16) ] } def _get_results(query: str): """Return real results from SearXNG if configured, else mock results.""" if SEARXNG_URL: try: qs = urllib.parse.urlencode({'q': query, 'format': 'json', 'categories': 'general'}) url = f"{SEARXNG_URL}/search?{qs}" with urllib.request.urlopen(url, timeout=8) as r: data = json.loads(r.read()) results = data.get('results', []) logger.info(f"Live SearXNG: {len(results)} results for '{query}'") return results, data.get('infoboxes', []), data.get('answers', []) except Exception as e: logger.warning(f"Live SearXNG failed ({e}), falling back to mock results") return MOCK_RESULTS['default'], [], [] # ── search route used by the demo UI ───────────────────────────────────────── _last_render = {'html': '', 'query': '', 'scores': [], 'intent': '—', 'summary': ''} @app.route('/dev/search') def dev_search(): query = request.args.get('q', 'why is the sky blue') global _last_render _last_render['intent'] = '…' _last_render['query'] = query class _MockSearch: class _MockSearchQuery: pageno = 1 lang = 'en-US' categories = ['general'] search_query = _MockSearchQuery() search_query.query = query class _MockResultContainer: def __init__(self, q): self.answers = set() self._q = q self.infoboxes = [] def get_ordered_results(self): results, iboxes, _ = _get_results(self._q) self.infoboxes = iboxes return results result_container = _MockResultContainer(query) session_id = secrets.token_hex(12) # Clear prior conversation so each search starts fresh try: from ollama_answers import _get_valkey vk = _get_valkey() vk.delete(f"ai:conv:{session_id}") except Exception: pass class _MockRequest: cookies = {'sxng_ai_session': session_id} headers = {} args = {} script_root = '' class _MockForm(dict): def get(self, key, default=None): return default # always return html format form = _MockForm() _score_handler.clear() search = _MockSearch() try: plugin.post_search(_MockRequest(), search) except Exception as e: logger.error(f"post_search failed: {e}", exc_info=True) return f"
post_search error: {e}
" html_payload = "" if search.result_container.answers: raw = list(search.result_container.answers)[0] # Markup objects need str() to get the HTML html_payload = str(raw) if raw else "" logger.info(f"Got AI answer, length: {len(html_payload)}") if html_payload: import re as _re text_preview = _re.sub(r'<[^>]+>', '', html_payload) text_preview = ' '.join(text_preview.split())[:300] logger.info(f"AI output preview: {text_preview}") else: logger.warning("post_search produced no answers") # Parse intent, per-URL TF-IDF scores, and rerank summary from captured log records # Parse intent, per-URL TF-IDF scores, and rerank summary from captured log records import re as _re detected_intent = '—' scores = [] fallback_domains = set() summary = '' for msg in _score_handler.records: m = _re.match(r'AI Answers: \[(.+?)\] score=([\d.]+) chunks=(\d+)', msg) if m: scores.append({ 'url': m.group(1), 'score': float(m.group(2)), 'chunks': int(m.group(3)), 'fallback': False }) m = _re.match(r'AI Answers: falling back to snippet for \[\d+\] (.+)', msg) if m: fallback_domains.add(m.group(1)) m = _re.match(r'AI Answers: reranked (\d+) results, top score=([\d.]+)', msg) if m: summary = f"{m.group(1)} reranked · top {float(m.group(2)):.4f}" for s in scores: domain = s['url'].replace('https://', '').replace('http://', '').split('/')[0] if domain in fallback_domains: s['fallback'] = True scores.sort(key=lambda s: s['score'], reverse=True) # Refresh latest intent from Valkey try: from ollama_answers import _get_valkey vk = _get_valkey() latest_intent = vk.get("ai:last_intent") if latest_intent: detected_intent = latest_intent except Exception: pass _last_render['html'] = html_payload _last_render['query'] = query _last_render['intent'] = detected_intent _last_render['scores'] = scores _last_render['summary'] = summary global _last_job _last_job['intent'] = detected_intent _last_job['query'] = query logger.warning(f"RETURNING INTENT TO UI: {repr(_last_job.get('intent'))}") return jsonify({'ok': True, 'query': query}) @app.route('/dev/render') def dev_render(): html = _last_render.get('html', '') return f"""Enter a query above and click Search to test the plugin.
Test plugin routes directly. Token is auto-injected.