""" title: ZP Wiki RAG description: Hybridné vyhľadávanie textových podkladov v ZP Wiki. version: 1.0.0 required_open_webui_version: 0.9.4 """ import asyncio import json import urllib.error import urllib.parse import urllib.request from pydantic import BaseModel, Field TOOL_ID = 'zpwiki_rag' ENDPOINT = '/rag' MAX_RESPONSE_BYTES = 2 * 1024 * 1024 class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # Never forward the backend credential to a redirected host. return None class Tools: class Valves(BaseModel): BACKEND_URL: str = Field(default='', description='Adresa ZP Agent API dostupná zo servera OpenWebUI, bez /rag') BACKEND_API_KEY: str = Field(default='', description='SEARCH_API_KEY backendu', json_schema_extra={'input': {'type': 'password'}}) TIMEOUT_SECONDS: int = Field(default=30, ge=1, le=120) LIMIT: int = Field(default=5, ge=1, le=20) PUBLISHED_ONLY: bool = Field(default=True, description='Skryť iba explicitné published=false; chýbajúca hodnota sa zahrnie') MAX_PER_DOCUMENT: int = Field(default=1, ge=0, le=10) def __init__(self): self.valves = self.Valves() def _error(self, reason, http_status=None): result = {'status': 'error', 'reason': reason, 'sources': [], 'index_version': None, 'complete': False, 'truncated': False, 'truncation_reasons': [], 'tool_id': TOOL_ID, 'output_kind': 'retrieval_context', 'requires_final_answer_verification': True} if http_status is not None: result['http_status'] = http_status return result def _url(self): url = self.valves.BACKEND_URL.strip().rstrip('/') parsed = urllib.parse.urlsplit(url) if (parsed.scheme not in {'http', 'https'} or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or any(char.isspace() for char in url)): raise ValueError('Invalid backend URL') parsed.port # Validate the port without exposing the configured URL. key = self.valves.BACKEND_API_KEY.strip() if not key or '\r' in key or '\n' in key: raise ValueError('Missing or invalid backend key') return url + ENDPOINT, key def _validate(self, result): required = {'status', 'reason', 'sources', 'index_version', 'complete', 'truncated', 'truncation_reasons'} if not isinstance(result, dict) or not required <= result.keys(): raise ValueError('Missing response contract') if result['status'] not in {'ok', 'no_answer', 'ambiguous', 'error'}: raise ValueError('Invalid status') if result['reason'] is not None and not isinstance(result['reason'], str): raise ValueError('Invalid reason') if result['index_version'] is not None and not isinstance(result['index_version'], dict): raise ValueError('Invalid version') if result['complete'] is not None and type(result['complete']) is not bool: raise ValueError('Invalid completeness') if type(result['truncated']) is not bool or (result['truncated'] and result['complete'] is not False): raise ValueError('Contradictory completeness') if not isinstance(result['truncation_reasons'], list) or not all(isinstance(x, str) for x in result['truncation_reasons']): raise ValueError('Invalid truncation reasons') if not isinstance(result['sources'], list): raise ValueError('Invalid sources') for source in result['sources']: if (not isinstance(source, dict) or not isinstance(source.get('source_id'), str) or not isinstance(source.get('document_path'), str) or (source.get('source_url') is not None and not isinstance(source['source_url'], str))): raise ValueError('Invalid source reference') if not isinstance(result.get('context'), str) and result['status'] == 'ok': raise ValueError('Missing text context') if result['truncation_reasons'] and not result['truncated']: raise ValueError('Unreported truncation') # Diagnostics can contain repeated evidence and are never model input. clean = {key: value for key, value in result.items() if key not in {'diagnostics', 'detail', 'answer', 'accepted_for_delivery', 'attempts'}} return {**clean, 'tool_id': TOOL_ID, 'output_kind': 'retrieval_context', 'requires_final_answer_verification': True} def _request(self, query): try: url, key = self._url() except (ValueError, TypeError): return self._error('invalid_configuration') body = {'query': query, 'limit': self.valves.LIMIT, 'published_only': self.valves.PUBLISHED_ONLY, 'max_per_document': self.valves.MAX_PER_DOCUMENT, 'include_diagnostics': False} request = urllib.request.Request(url, data=json.dumps(body, ensure_ascii=False).encode(), headers={'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + key}, method='POST') try: opener = urllib.request.build_opener(_NoRedirect()) with opener.open(request, timeout=self.valves.TIMEOUT_SECONDS) as response: raw = response.read(MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: code = error.code error.close() reason = {401: 'unauthorized', 403: 'forbidden', 404: 'backend_endpoint_not_found', 400: 'invalid_query', 422: 'invalid_request', 429: 'backend_busy', 503: 'retrieval_unavailable'}.get(code, 'backend_http_error') return self._error('redirect_not_allowed' if 300 <= code < 400 else reason, code) except (TimeoutError, urllib.error.URLError, OSError) as error: timeout = isinstance(error, TimeoutError) or isinstance(getattr(error, 'reason', None), TimeoutError) return self._error('backend_timeout' if timeout else 'backend_unavailable') if len(raw) > MAX_RESPONSE_BYTES: return self._error('response_too_large') try: return self._validate(json.loads(raw)) except (ValueError, TypeError, KeyError): return self._error('invalid_backend_response') async def retrieve_zpwiki_context(self, query: str) -> str: """Vyhľadaj textové podklady o prácach, témach a osobách v ZP Wiki. Zachovaj celú otázku vrátane mien, typu práce, roku a ďalších podmienok. Výstup je JSON podkladov, nie overená finálna odpoveď. Rešpektuj status, complete, truncated, zdroje a verziu dát. Pri chybe nič nedomýšľaj. :param query: Celá vyhľadávacia otázka, najviac 500 znakov. :return: Kontext, zdroje a spoločné metadáta alebo bezpečne označená chyba. """ if not isinstance(query, str) or not 1 <= len(query.strip()) <= 500: result = self._error('invalid_query') else: try: result = await asyncio.to_thread(self._request, query.strip()) except Exception: result = self._error('backend_unavailable') return json.dumps(result, ensure_ascii=False, separators=(',', ':'))