diff --git a/.gitignore b/.gitignore
index f4c2ae9..b13f288 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ __pycache__/
*$py.class
venv/
.env
+dev/.env
.idea/
.vscode/
.agent/
\ No newline at end of file
diff --git a/README.md b/README.md
index 1f7fd14..99209b5 100644
--- a/README.md
+++ b/README.md
@@ -104,14 +104,44 @@ Configure via environment variables.
## Known Issues
-- [ ] When asking a follow up question the previous output disappears
-- [ ] Parts of the UI are not theme aware resulting in a unpolished look when not using a dark theme
-- [ ] When SearXNG provides a info blob for a search it appears on top of the overview i.e. `Wikipedia` or `Linux`
+- [ ] Update README with all updates
+
+- [x] When asking a follow up question the previous output disappears
+
+- [x] Parts of the UI are not theme aware resulting in a unpolished look when not using a dark theme
+
+- [x] When SearXNG provides a info blob for a search it appears on top of the overview i.e. `Wikipedia` or `Linux`
For any issues not stated here please create an issue ticket on [Gitea](https://git.tysstech.com/TySS-Dev/ollama-ai-answers-searxng/issues) or [GitHub](https://github.com/TySP-Dev/ollama-ai-answers-searxng/issues) and add the `bug` tag.
## Roadmap
+### Dev Server
+
+- [x] Stream viewer — show tokens arriving in real time in the debug panel as they come out of Valkey, so you can see exactly what the LLM is generating chunk by chunk
+
+- [x] TF-IDF score visualizer — show a table of which URLs were fetched, their scores, and which chunks were selected for context
+
+- [ ] Intent detection display — show what intent was detected and which system prompt was used for each query
+
+- [ ] Saved queries — save/load test queries so you can quickly re-run the same set of searches after making changes to the plugin
+
+- [ ] A/B model comparison — run the same query against two different models simultaneously and show both responses side by side
+
+- [ ] Response time breakdown — show how long each phase took: SearXNG fetch, page fetching, TF-IDF scoring, LLM stream start, stream complete
+
+- [ ] Context inspector — show the full assembled context string that gets sent to the LLM, so you can see exactly what it's working with
+
+- [ ] Prompt viewer — show the full system prompt + user prompt that gets sent to Ollama
+
+- [ ] Export button — copy the full context + prompt + response as a JSON blob for bug reports
+
+- [ ] Per-intent system prompt editor — edit the system prompts for each intent type live without restarting
+
+- [ ] Token counter — show estimated token count of the context being sent
+
+### Plugin
+
- [ ] Working on feature plans
## Architecture
diff --git a/dev/.env.example b/dev/.env.example
new file mode 100644
index 0000000..af56ba1
--- /dev/null
+++ b/dev/.env.example
@@ -0,0 +1,39 @@
+# AI Answers Plugin — Dev Server Config
+# Copy this to .env and fill in your values
+# .env is gitignored and never committed
+
+# Ollama endpoint (required)
+LLM_URL=http://localhost:11434/v1/chat/completions
+
+# Default model
+LLM_MODEL=qwen3.5:9b
+
+# Max response tokens
+LLM_MAX_TOKENS=200
+
+# Response temperature (0.0 - 2.0)
+LLM_TEMPERATURE=0.2
+
+# Bearer token for authenticated LLM endpoints
+# Leave empty if no Bearer token is needed for your server
+LLM_API_KEY=
+
+# Live SearXNG instance for real search results
+# Leave empty to use mock results
+SEARXNG_URL=
+
+# Valkey for streaming (required)
+# Start with: docker run -d --name dev-valkey -p 6379:6379 valkey/valkey:9-alpine
+VALKEY_HOST=localhost
+VALKEY_PORT=6379
+
+# Dev server host and port
+DEV_HOST=127.0.0.1
+DEV_PORT=5000
+
+# Plugin settings
+LLM_INTERACTIVE=true
+LLM_QUESTION_MARK_REQUIRED=false
+LLM_TABS=general,science,it,news
+LLM_CONTEXT_DEEP_COUNT=5
+LLM_CONTEXT_SHALLOW_COUNT=15
diff --git a/dev/dev.py b/dev/dev.py
new file mode 100644
index 0000000..3b24c2e
--- /dev/null
+++ b/dev/dev.py
@@ -0,0 +1,1473 @@
+"""
+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"""
+
+
+
+
+
+
+
+
+"""
+
+# ── dev API log endpoint ──────────────────────────────────────────────────────
+_last_job = {'job_id': None, 'intent': None, 'model': None,
+ 'query': None, 'start_ts': None}
+
+def _get_last_job_from_log():
+ for entry in _dev_log:
+ if '/ai-stream' in entry.get('path', '') and entry.get('method') == 'POST':
+ try:
+ resp = json.loads(entry.get('resp', '{}'))
+ if resp.get('job_id'):
+ return resp['job_id']
+ except Exception:
+ pass
+ return None
+
+@app.route('/dev/log')
+def dev_log():
+ return jsonify(_dev_log)
+
+@app.route('/dev/log/clear', methods=['POST'])
+def dev_log_clear():
+ _dev_log.clear()
+ return jsonify({'ok': True})
+
+@app.route('/dev/last-job')
+def dev_last_job():
+ return jsonify(_last_job)
+
+@app.route('/dev/last-scores')
+def dev_last_scores():
+ return jsonify({
+ 'scores': _last_render.get('scores', []),
+ 'intent': _last_render.get('intent', '—'),
+ 'query': _last_render.get('query', ''),
+ 'summary': _last_render.get('summary', ''),
+ })
+
+@app.route('/dev/stream-watch')
+def dev_stream_watch():
+ job_id = request.args.get('job_id', '')
+ if not job_id:
+ return jsonify({'error': 'no job_id'}), 400
+
+ def generate():
+ import valkey as _vk
+ host = os.getenv('VALKEY_HOST', 'localhost')
+ port = int(os.getenv('VALKEY_PORT', 6379))
+ v = _vk.Valkey(host=host, port=port,
+ socket_connect_timeout=2,
+ decode_responses=True)
+ offset = 0
+ max_polls = 600
+ polls = 0
+ while polls < max_polls:
+ polls += 1
+ time.sleep(0.1)
+ try:
+ chunks = v.lrange(f"ai:job:{job_id}:chunks", offset, -1)
+ for chunk in chunks:
+ if chunk == '__DONE__':
+ yield "data: __DONE__\n\n"
+ return
+ if chunk.startswith('__ERROR__'):
+ yield f"data: __ERROR__{chunk[9:]}\n\n"
+ return
+ yield f"data: {json.dumps(chunk)}\n\n"
+ offset += len(chunks)
+ except Exception as e:
+ yield f"data: __ERROR__{e}\n\n"
+ return
+ yield "data: __DONE__\n\n"
+
+ return Response(generate(),
+ mimetype='text/event-stream',
+ headers={
+ 'Cache-Control': 'no-cache',
+ 'X-Accel-Buffering': 'no'
+ })
+
+# ── dev config update ─────────────────────────────────────────────────────────
+@app.route('/dev/config', methods=['POST'])
+def dev_config():
+ global SEARXNG_URL
+ data = request.json or {}
+ if 'llm_url' in data:
+ url = data.get('llm_url', '').strip()
+ if url:
+ plugin.endpoint_url = url
+ os.environ['LLM_URL'] = url
+ if 'model' in data and data['model']:
+ plugin.model = data['model'].strip()
+ os.environ['LLM_MODEL'] = plugin.model
+ if 'max_tokens' in data:
+ try:
+ plugin.max_tokens = max(1, int(data['max_tokens']))
+ except (ValueError, TypeError):
+ pass
+ if 'temperature' in data:
+ try:
+ plugin.temperature = float(data['temperature'])
+ except (ValueError, TypeError):
+ pass
+ if 'api_key' in data:
+ plugin.api_key = data['api_key'].strip() or 'ollama'
+ if 'searxng_url' in data:
+ SEARXNG_URL = data['searxng_url'].strip().rstrip('/')
+ if 'valkey_host' in data or 'valkey_port' in data:
+ host = data.get('valkey_host', os.getenv('VALKEY_HOST', 'localhost')).strip()
+ port = int(data.get('valkey_port', os.getenv('VALKEY_PORT', 6379)))
+ os.environ['VALKEY_HOST'] = host
+ os.environ['VALKEY_PORT'] = str(port)
+ # Reset the valkey connection pool so it picks up new settings
+ import ollama_answers as _oa
+ _oa._VALKEY_POOL = None
+ return jsonify({
+ 'llm_url': plugin.endpoint_url,
+ 'model': plugin.model,
+ 'max_tokens': plugin.max_tokens,
+ 'temperature': plugin.temperature,
+ 'api_key': plugin.api_key,
+ 'searxng_url': SEARXNG_URL or None,
+ })
+
+@app.route('/dev/config', methods=['GET'])
+def dev_config_get():
+ return jsonify({
+ 'llm_url': plugin.endpoint_url,
+ 'model': plugin.model,
+ 'max_tokens': plugin.max_tokens,
+ 'temperature': plugin.temperature,
+ 'api_key': plugin.api_key,
+ 'searxng_url': SEARXNG_URL or None,
+ 'interactive': plugin.interactive,
+ 'allowed_tabs': list(plugin.allowed_tabs),
+ 'valkey_host': os.getenv('VALKEY_HOST', 'localhost'),
+ 'valkey_port': int(os.getenv('VALKEY_PORT', 6379)),
+ })
+
+# ── main dev UI ───────────────────────────────────────────────────────────────
+@app.route('/')
+def index():
+ return DEV_HTML
+
+DEV_HTML = """
+
+
+
+
+AI Answers — Dev Server
+
+
+
+
+
+ ✦ AI Answers Dev
+ Mock Results
+ Ollama: checking…
+ Valkey: checking…
+
+
+
+
+
+
+
+
+
+
+
+
Enter a query above and click Search to test the plugin.
+
+
+
+ TF-IDF Relevance Scores
+
+
+
+
+
+
+
+
+
+
Config
+
Debug Log
+
Stream
+
API Explorer
+
+
+
+
+
Plugin Config
+
loading…
+
Model—
+
Max Tokens—
+
Temperature—
+
Interactive—
+
Allowed Tabs—
+
SearXNG Backendmock
+
Valkey—
+
+
Edit Config
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Idle
+
+
+
+
+ Shows token chunks arriving from Valkey in real time during streaming.
+
+
+
+ Waiting for next search…
+
+
+ First token: —
+ |
+ Total: —
+
+
+
+
+
+
Test plugin routes directly. Token is auto-injected.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+# ── token endpoint (dev only) ─────────────────────────────────────────────────
+@app.route('/dev/token')
+def dev_token():
+ ts = str(time.time())
+ sig = hashlib.sha256(f"{ts}{plugin.secret}".encode()).hexdigest()
+ return jsonify({'token': f"{ts}.{sig}"})
+
+# ── ollama health check endpoint ──────────────────────────────────────────────
+@app.route('/dev/valkey-check')
+def dev_valkey_check():
+ try:
+ import valkey as _vk
+ host = os.getenv('VALKEY_HOST', 'localhost')
+ port = int(os.getenv('VALKEY_PORT', 6379))
+ v = _vk.Valkey(host=host, port=port, socket_connect_timeout=2)
+ v.ping()
+ return jsonify({'ok': True, 'host': host, 'port': port})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)})
+
+@app.route('/dev/ollama-check')
+def dev_ollama_check():
+ try:
+ p = urllib.parse.urlparse(plugin.endpoint_url)
+ base = f"{p.scheme}://{p.netloc}"
+ url = f"{base}/api/tags"
+ req = urllib.request.Request(url, headers={'Authorization': f'Bearer {plugin.api_key}'})
+ with urllib.request.urlopen(req, timeout=3) as r:
+ data = json.loads(r.read())
+ models = len(data.get('models', []))
+ return jsonify({'ok': True, 'models': models})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)})
+
+# ── entry point ───────────────────────────────────────────────────────────────
+if __name__ == '__main__':
+ print(f"""
+ ✦ AI Answers Dev Server
+ ─────────────────────────────────────────
+ UI: http://{DEV_HOST}:{DEV_PORT}
+ LLM: {plugin.endpoint_url}
+ Model: {plugin.model}
+ Max tokens: {plugin.max_tokens}
+ Temperature: {plugin.temperature}
+ SearXNG: {SEARXNG_URL or 'mock results'}
+ ─────────────────────────────────────────
+""")
+ app.run(host=DEV_HOST, port=DEV_PORT, debug=False)
\ No newline at end of file
diff --git a/ollama_answers.py b/ollama_answers.py
index 83ac20d..0c19211 100644
--- a/ollama_answers.py
+++ b/ollama_answers.py
@@ -1,12 +1,11 @@
-import json, os, logging, base64, time, hashlib, re, http.client, ssl, concurrent.futures, threading, math
+import json, os, logging, base64, typing, time, hashlib, re, http.client, ssl, concurrent.futures, threading, math
from collections import Counter
from urllib.parse import urlparse
-from searx import network
try:
from searx.network import get_network
except ImportError:
get_network = None
-from flask import Response, request, abort, jsonify
+from flask import request, abort, jsonify
from searx.plugins import Plugin, PluginInfo
from searx.result_types import EngineResults
from searx import settings
@@ -24,7 +23,6 @@ except ImportError:
logger.warning("AI Answers: valkey package not found. Streaming via Valkey unavailable.")
TOKEN_EXPIRY_SEC = 3600
-STREAM_CHUNK_SIZE = 512
STREAM_TIMEOUT_SEC = 60
CONV_TTL = 1800
@@ -276,17 +274,17 @@ INTERACTIVE_CSS = '''
width: 32px;
height: 32px;
padding: 0;
- border: none;
+ border: 1px solid var(--color-result-border, rgba(0,0,0,0.1));
border-radius: 4px;
- background: var(--color-sidebar-bg, #424247);
- color: var(--color-search-url, #bbb);
+ background: var(--color-base-background-hover, rgba(0,0,0,0.06));
+ color: var(--color-base-font, inherit);
cursor: pointer;
vertical-align: middle;
line-height: 1.4;
}
.sxng-btn:hover {
- background: var(--color-search-url, #303033);
- color: var(--color-sidebar-bg, #bbb);
+ background: var(--color-result-border, rgba(0,0,0,0.15));
+ color: var(--color-base-font, inherit);
}
.sxng-btn svg { width: 18px; height: 18px; fill: currentColor; }
.sxng-input-wrapper {
@@ -300,9 +298,9 @@ INTERACTIVE_CSS = '''
.sxng-input {
width: 100%;
height: -webkit-fill-available;
- background: var(--color-sidebar-bg, #424247);
- border: none;
- color: var(--color-base-font, #cdd6f4);
+ background: var(--color-base-background-hover, rgba(0,0,0,0.06));
+ border: 1px solid var(--color-result-border, rgba(0,0,0,0.15));
+ color: var(--color-base-font, inherit);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 0.78em;
padding: 3px 8px;
@@ -311,7 +309,7 @@ INTERACTIVE_CSS = '''
vertical-align: middle;
}
.sxng-input:focus { outline: none; }
- .sxng-input::placeholder { color: var(--color-base-font, #333); opacity: 0.35; }
+ .sxng-input::placeholder { color: var(--color-base-font, inherit); opacity: 0.4; }
.sxng-input-line {
position: absolute;
bottom: 0;
@@ -335,23 +333,24 @@ INTERACTIVE_CSS = '''
opacity: 0.55;
animation: sxng-fade-in-up 0.3s ease-out forwards;
}
- .sxng-input-wrapper:focus-within {
- opacity: 1;
- color: var(--color-result-link, #5e81ac);
+ .sxng-input-wrapper:focus-within {
+ opacity: 1;
+ color: var(--color-result-link, #5e81ac);
background: var(--color-base-background-hover, rgba(0,0,0,0.05)) !important;
}
.sxng-model-select {
appearance: none;
-webkit-appearance: none;
background: url("data:image/svg+xml;charset=UTF-8,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%0A%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%3E%0A%3Cg%20fill%3D%22%23aaa%22%3E%0A%3Cpolygon%20points%3D%22128%2C192%20256%2C320%20384%2C192%22%2F%3E%3C%2Fg%3E%0A%3C%2Fsvg%3E") calc(100% + 2rem) / 1rem no-repeat content-box border-box;
- background-color: #424247;
+ background-color: var(--color-base-background-hover, rgba(0,0,0,0.06));
text-overflow: ellipsis;
- border-width: 0 2rem 0 0;
- border-color: transparent;
+ border: 0px solid var(--color-result-border, rgba(0,0,0,0.1));
+ border-right-width: 2rem;
+ border-right-color: transparent;
border-radius: 5px;
outline: none;
height: 25px;
- color: var(--color-search-url, #bbb);
+ color: var(--color-base-font, inherit);
font-size: .9rem;
padding: 1px 10px 1px 10px !important;
margin: 0;
@@ -361,8 +360,7 @@ INTERACTIVE_CSS = '''
vertical-align: middle;
}
.sxng-model-select:hover {
- background-color: #303033;
- color: var(--color-search-url, #bbb);
+ background-color: var(--color-result-border, rgba(0,0,0,0.15));
}
.sxng-reasoning {
margin: 0.5rem 0; padding: 0.5rem;
@@ -385,7 +383,8 @@ INTERACTIVE_CSS = '''
font-size: 0.75em;
color: var(--color-result-link, #5e81ac);
text-decoration: none;
- opacity: 0.75;
+ opacity: 1;
+ font-weight: 600;
}
.sxng-citation-item a:hover {
opacity: 1;
@@ -395,18 +394,18 @@ INTERACTIVE_CSS = '''
margin-bottom: 0.75rem;
padding: 0.5rem;
border-left: 2px solid var(--color-result-link, #5e81ac);
- opacity: 0.6;
+ opacity: 0.85;
font-size: 0.85em;
}
.sxng-prior-history summary {
cursor: pointer;
color: var(--color-result-link, #5e81ac);
- font-weight: 600;
+ font-weight: 700;
}
.sxng-prior-answer {
margin: 0.25rem 0;
padding-left: 0.5rem;
- color: var(--color-base-font, #cdd6f4);
+ color: var(--color-base-font, inherit);
}
.sxng-md-content {
line-height: 1.6;
@@ -507,7 +506,7 @@ CITATION_HELPER_JS = r'''
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
let lastIdx = 0;
const matches = [...text.matchAll(re)];
-
+
matches.forEach(match => {
if (match.index > lastIdx) {
const s = document.createElement('span');
@@ -542,7 +541,7 @@ CITATION_HELPER_JS = r'''
});
lastIdx = match.index + match[0].length;
});
-
+
if (lastIdx < text.length) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
@@ -600,23 +599,6 @@ INTERACTIVE_JS = r'''
_ms.appendChild(_o);
}
}
- if (window.getComputedStyle && box) {
- try {
- const docStyles = getComputedStyle(document.documentElement);
- let accent = docStyles.getPropertyValue('--color-result-link').trim();
- if (!accent) {
- const a = document.createElement('a');
- document.body.appendChild(a);
- accent = getComputedStyle(a).color;
- document.body.removeChild(a);
- }
- if (accent) {
- box.style.setProperty('--color-result-link', accent);
- box.style.setProperty('--sxng-ai-accent', accent);
- }
- } catch(e) {}
- }
-
// conversation saved as base64 URL fragment.
const updateState = () => {
try {
@@ -636,13 +618,13 @@ INTERACTIVE_JS = r'''
}
return btoa(bin);
};
-
+
let b64 = encodeB64(state);
while (b64.length > 2000 && state.t.length > 2) {
state.t.splice(1, 2); // Delete in Q&A pairs
b64 = encodeB64(state);
}
-
+
history.replaceState(null, null, '#ai=' + b64);
} catch(e) {}
};
@@ -658,17 +640,17 @@ INTERACTIVE_JS = r'''
if (state.u && Array.isArray(state.u)) {
urls = state.u;
}
-
+
conversation.turns = state.t.map(t => ({
role: t.r === 'u' ? 'user' : 'assistant',
content: t.c.trim(),
ts: 0
}));
-
+
const injectCitations = (text) => {
return renderCitations(text, urls);
};
-
+
data.innerHTML = '';
conversation.turns.forEach((turn, i) => {
if (turn.role === 'user') {
@@ -686,7 +668,6 @@ INTERACTIVE_JS = r'''
}
});
box.style.display = 'block';
- if(wrapper) wrapper.style.display = '';
if(footer && is_interactive) footer.style.display = 'flex';
restored = true;
}
@@ -756,10 +737,10 @@ INTERACTIVE_JS = r'''
const handleAction = async (e) => {
if (e) e.preventDefault();
const val = input.value.trim();
-
+
conversation.turns.push({role: 'user', content: val, ts: Date.now()});
updateState();
-
+
const currentText = conversation.turns.slice(0, -1).slice(-6)
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
.join('\\n\\n');
@@ -782,7 +763,7 @@ INTERACTIVE_JS = r'''
const newCursor = document.createElement('span');
newCursor.className = 'sxng-cursor';
data.appendChild(newCursor);
-
+
const synthesized = synthesizeQuery(q_init, val);
let auxContext = null;
try {
@@ -799,7 +780,7 @@ INTERACTIVE_JS = r'''
}
}
} catch (err) {}
-
+
await startStream(val, currentText, auxContext);
updateState();
} else {
@@ -871,16 +852,92 @@ FRONTEND_JS_TEMPLATE = r"""
const conversation = {
originalQuery: q_init,
originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))),
- originalSources: [...urls],
turns: [{role: 'user', content: q_init, ts: Date.now()}]
};
const box = document.getElementById('sxng-stream-box');
const data = document.getElementById('sxng-stream-data');
- const wrapper = box.closest('.answer');
- if (wrapper) wrapper.style.display = 'none';
+
+ (function applyTheme() {
+ try {
+ const root = document.documentElement;
+ const s = getComputedStyle(root);
+ const get = (v, fallback) => s.getPropertyValue(v).trim() || fallback;
+
+ const theme = {
+ '--color-answer-background': get('--color-answer-background', '#313338'),
+ '--color-answer-font': get('--color-answer-font', '#fff'),
+ '--color-result-link': get('--color-result-link', '#8aacf7'),
+ '--color-base-font': get('--color-base-font', '#cdd6f4'),
+ '--color-sidebar-bg': get('--color-sidebar-bg', '#424247'),
+ '--color-result-hover': get('--color-result-hover', '#303033'),
+ '--color-base-background': get('--color-base-background', '#2a2a2e'),
+ '--color-search-font': get('--color-search-font', '#bbb'),
+ '--color-result-border': get('--color-result-border', '#4c566a'),
+ '--color-result-description':get('--color-result-description', '#d8dee9'),
+ '--color-toolkit-select-background': get('--color-toolkit-select-background', '#313338'),
+ };
+
+ // Apply to box and any ai-answers container
+ const targets = [box, document.getElementById('ai-answers')].filter(Boolean);
+ targets.forEach(el => {
+ Object.entries(theme).forEach(([k, v]) => {
+ if (v) el.style.setProperty(k, v);
+ });
+ });
+
+ } catch(e) {}
+ })();
+
+ // Move AI Overview outside #answers, place it before #results
+ (function relocateBox() {
+ const answersDiv = document.getElementById('answers');
+
+ if (!box || !answersDiv) return;
+
+ // Create our own container
+ const aiContainer = document.createElement('div');
+ aiContainer.id = 'ai-answers';
+ const rootStyle = getComputedStyle(document.documentElement);
+ const getVar = (v, fb) => rootStyle.getPropertyValue(v).trim() || fb;
+ const bg = getVar('--color-answer-background', '');
+ const answerFont = getVar('--color-answer-font', '');
+ // Detect light mode by checking if answer font is dark
+ const isLight = answerFont && (answerFont.includes('0,0,0') ||
+ answerFont.includes('#000') || answerFont.includes('#333') ||
+ answerFont.includes('#444') || answerFont.includes('rgb(0') ||
+ answerFont.includes('rgb(3') || answerFont.includes('rgb(4') ||
+ answerFont.includes('rgb(5') || answerFont.includes('rgb(6'));
+ const containerBg = isLight
+ ? 'rgba(0,0,0,0.06)'
+ : (bg || 'var(--color-answer-background, #313338)');
+ aiContainer.style.cssText = [
+ `background: ${containerBg}`,
+ 'padding: 1rem',
+ 'margin: 0 0 1rem 0',
+ `color: ${getVar('--color-answer-font', 'var(--color-answer-font, #fff)')}`,
+ 'border-radius: 8px',
+ 'box-sizing: border-box',
+ 'width: 100%'
+ ].join('; ');
+
+ // Move our box into the new container
+ aiContainer.appendChild(box);
+
+ const resultsGrid = document.getElementById('results');
+ if (resultsGrid) {
+ // Insert as first child of #results grid so grid-area:answers applies
+ resultsGrid.insertBefore(aiContainer, resultsGrid.firstChild);
+ } else {
+ answersDiv.parentNode.insertBefore(aiContainer, answersDiv);
+ }
+
+ // Hide #answers entirely since our box is now elsewhere
+ answersDiv.style.display = 'none';
+ })();
+
let restored = false;
let isStreaming = false;
-
+
__CITATION_HELPER_JS__
(function applyIntentBadge() {
@@ -943,11 +1000,10 @@ FRONTEND_JS_TEMPLATE = r"""
console.warn('[AI Answers] Stream already in progress, ignoring duplicate call');
return;
}
-
+
isStreaming = true;
try {
const ctx = auxContext || conversation.originalContext;
- if (wrapper) wrapper.style.display = '';
box.style.display = 'block';
const controller = new AbortController();
@@ -1002,6 +1058,11 @@ FRONTEND_JS_TEMPLATE = r"""
data.appendChild(cursor);
}
+ const streamContainer = document.createElement('div');
+ streamContainer.className = 'sxng-stream-container';
+ if (cursor) cursor.before(streamContainer);
+ else data.appendChild(streamContainer);
+
let buffer = '';
let fullText = '';
const flushBuffer = (force = false) => {
@@ -1009,8 +1070,7 @@ FRONTEND_JS_TEMPLATE = r"""
if (force) {
const fragment = renderCitations(buffer, urls);
- if (cursor) cursor.before(fragment);
- else data.appendChild(fragment);
+ streamContainer.appendChild(fragment);
buffer = '';
return;
}
@@ -1025,12 +1085,12 @@ FRONTEND_JS_TEMPLATE = r"""
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = preText;
- cursor.before(s);
+ streamContainer.appendChild(s);
}
const citationText = match[0];
const fragment = renderCitations(citationText, urls);
- cursor.before(fragment);
+ streamContainer.appendChild(fragment);
buffer = buffer.substring(match.index + match[0].length);
}
@@ -1041,7 +1101,7 @@ FRONTEND_JS_TEMPLATE = r"""
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = buffer;
- cursor.before(s);
+ streamContainer.appendChild(s);
buffer = '';
}
} else {
@@ -1050,7 +1110,7 @@ FRONTEND_JS_TEMPLATE = r"""
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = safeChunk;
- cursor.before(s);
+ streamContainer.appendChild(s);
}
buffer = buffer.substring(openIdx);
@@ -1058,7 +1118,7 @@ FRONTEND_JS_TEMPLATE = r"""
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = buffer[0];
- cursor.before(s);
+ streamContainer.appendChild(s);
buffer = buffer.substring(1);
}
}
@@ -1120,11 +1180,9 @@ FRONTEND_JS_TEMPLATE = r"""
}
}
+ streamContainer.remove();
if (cursor) cursor.remove();
- // Remove only the streamed chunk spans added during this stream
- Array.from(data.querySelectorAll('.sxng-chunk')).forEach(node => node.remove());
-
const rendered = parseMarkdown(fullText.trim());
const mdDiv = document.createElement('div');
mdDiv.className = 'sxng-md-content';
@@ -1151,13 +1209,13 @@ FRONTEND_JS_TEMPLATE = r"""
console.error('[AI Answers] Fatal stream exception:', e);
const errSpan = document.createElement('span');
errSpan.style.cssText = 'color: #bf616a; font-weight: bold; display: block; margin-top: 0.5rem;';
-
+
if (e.name === 'AbortError') {
errSpan.textContent = "⚠️ Connection to AI provider timed out.";
} else {
errSpan.textContent = "⚠️ AI Widget encountered a fatal error. Check browser console.";
}
-
+
if (data) {
const cursor = data.querySelector('.sxng-cursor');
if (cursor) cursor.remove();
@@ -1288,8 +1346,6 @@ INTENT_CONFIGS = {
},
}
-
-import typing
if typing.TYPE_CHECKING:
from searx.search import SearchWithPlugins
from searx.extended_types import SXNG_Request
@@ -1379,7 +1435,7 @@ class SXNGPlugin(Plugin):
'content': str(ib.get('content') or '')[:2000],
'attributes': ib.get('attributes', [])
})
-
+
answers = []
for a in list(raw_answers)[:2]:
ans_text = ""
@@ -1389,7 +1445,7 @@ class SXNGPlugin(Plugin):
ans_text = str(a['answer'])
if ans_text and 'id="sxng-stream-box"' not in ans_text and not ans_text.strip().startswith('<'):
answers.append(ans_text)
-
+
return results, infoboxes, answers
def init(self, app):
@@ -1397,10 +1453,10 @@ class SXNGPlugin(Plugin):
def ai_auxiliary_search():
if not self.api_key:
abort(403)
-
+
data = request.json or {}
token = data.get('tk', '')
-
+
# Token access control
try:
ts, sig = token.rsplit('.', 1)
@@ -1420,13 +1476,13 @@ class SXNGPlugin(Plugin):
offset = data.get('offset', 0)
if not query:
return jsonify({'results': []})
-
+
try:
from searx.search import SearchWithPlugins
from searx.search.models import SearchQuery
from searx.query import RawTextQuery
from searx.webadapter import get_engineref_from_category_list
-
+
preferences = getattr(request, 'preferences', None)
disabled_engines = preferences.engines.get_disabled() if preferences else []
rtq = RawTextQuery(query, disabled_engines)
@@ -1434,7 +1490,7 @@ class SXNGPlugin(Plugin):
category_list = [c.strip() for c in categories.split(',') if c.strip()]
else:
category_list = categories or ['general']
-
+
enginerefs = get_engineref_from_category_list(category_list, disabled_engines)
sq = SearchQuery(
query=rtq.getQuery(),
@@ -1444,19 +1500,19 @@ class SXNGPlugin(Plugin):
)
search_obj = SearchWithPlugins(sq, request, user_plugins=[])
result_container = search_obj.search()
-
+
raw_results = result_container.get_ordered_results()
raw_infoboxes = getattr(result_container, 'infoboxes', [])
raw_answers = getattr(result_container, 'answers', [])
-
+
results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers)
-
+
context_str, new_urls = self._assemble_context(results, infoboxes, answers, offset)
return jsonify({
'context': context_str,
'new_urls': new_urls,
- 'results': results,
+ 'results': results,
'infoboxes': infoboxes,
'answers': answers,
'query': query
@@ -1669,6 +1725,16 @@ class SXNGPlugin(Plugin):
job_id = hashlib.sha256(f"{time.time()}{q}".encode()).hexdigest()[:16]
+ # Persist intent for dev UI
+ logger.warning(f"INTENT BEFORE PERSIST: {repr(intent)}")
+ logger.warning(f"JOB_ID BEFORE PERSIST: {repr(job_id)}")
+ try:
+ vk = _get_valkey()
+ vk.setex(f"ai:job:{job_id}:intent", 3600, intent)
+ logger.debug(f"{PLUGIN_NAME}: persisted intent '{intent}' for job {job_id}")
+ except Exception:
+ logger.exception(f"{PLUGIN_NAME}: failed to persist intent")
+
payload_dict = {
"model": effective_model,
"messages": [
@@ -1876,12 +1942,12 @@ class SXNGPlugin(Plugin):
"""Builds context string from normalized search data. Returns (context_str, urls)."""
context_parts = []
result_urls = []
-
+
knowledge_graph_lines = []
for ib in infoboxes:
ib_name = ib.get('name', '') or ib.get('infobox', '') or ib.get('title', '')
ib_content = str(ib.get('content', '')).replace('\n', ' ').strip()
-
+
if ib_name:
parts = [f"INFOBOX [{ib_name}]:"]
if ib_content:
@@ -1891,16 +1957,16 @@ class SXNGPlugin(Plugin):
attr_value = attr.get('value', '')
if attr_label and attr_value:
parts.append(f" {attr_label}: {attr_value}")
-
+
knowledge_graph_lines.append(" ".join(parts) if len(parts) == 2 else "\n".join(parts))
for ans_text in answers:
if ans_text and not str(ans_text).startswith('<'):
knowledge_graph_lines.append(f"ANSWER: {str(ans_text)[:300]}")
-
+
if knowledge_graph_lines:
context_parts.append("KNOWLEDGE GRAPH:\n" + "\n".join(knowledge_graph_lines))
-
+
deep_lines = []
for i, r in enumerate(clean_results[:self.context_deep_count]):
url = r.get('url', '')
@@ -1916,10 +1982,10 @@ class SXNGPlugin(Plugin):
logger.debug(f"{PLUGIN_NAME}: falling back to snippet for [{idx}] {domain}")
content = str(r.get('content', '')).replace('\n', ' ').strip()[:800]
deep_lines.append(f"[{idx}] {domain}{date_str}: {title}: {content}")
-
+
if deep_lines:
context_parts.append("DEEP SOURCES:\n" + "\n".join(deep_lines))
-
+
if self.context_shallow_count > 0:
shallow_lines = []
start_idx = self.context_deep_count
@@ -1931,10 +1997,10 @@ class SXNGPlugin(Plugin):
title = r.get('title', '').replace('\n', ' ').strip()[:60]
idx = i + 1 + start_idx + offset
shallow_lines.append(f"[{idx}] {domain}: {title}")
-
+
if shallow_lines:
context_parts.append("SHALLOW SOURCES (headlines):\n" + "\n".join(shallow_lines))
-
+
return "\n\n".join(context_parts), result_urls
def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
@@ -1958,7 +2024,7 @@ class SXNGPlugin(Plugin):
raw_results = search.result_container.get_ordered_results()
raw_infoboxes = getattr(search.result_container, 'infoboxes', [])
raw_answers = getattr(search.result_container, 'answers', [])
-
+
q_clean = search.search_query.query.strip()
clean_results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers)
clean_results = self._enrich_results(clean_results, q_clean)
@@ -1981,12 +2047,23 @@ class SXNGPlugin(Plugin):
detected_intent = _detect_intent(q_clean)
js_intent = safe_json(detected_intent)
-
+
+ # Persist intent for dev tooling / UI
+ try:
+ vk = _get_valkey()
+ vk.setex(
+ f"ai:job:{job_id}:intent",
+ 1800,
+ detected_intent
+ )
+ except Exception as e:
+ logger.debug(f"{PLUGIN_NAME}: failed to persist intent: {e}")
+
b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8')
total_context_count = self.context_deep_count + self.context_shallow_count
-
+
raw_urls = [r.get('url', '') for r in clean_results[:total_context_count]]
-
+
js_q = safe_json(q_clean)
js_lang = safe_json(lang)
js_urls = safe_json(raw_urls)
@@ -2028,7 +2105,7 @@ class SXNGPlugin(Plugin):
.replace("__JS_Q__", js_q)
html_payload = f'''
-
+
-
+
{interactive_html}