144 lines
7.5 KiB
Python
144 lines
7.5 KiB
Python
"""
|
|
title: ZP Wiki GraphRAG
|
|
description: Grafové vyhľadávanie vzťahov, prác a agregácií 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_graphrag'
|
|
ENDPOINT = '/graphrag'
|
|
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 /graphrag')
|
|
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=40, ge=1, le=100)
|
|
PUBLISHED_ONLY: bool = Field(default=True, description='Skryť iba explicitné published=false; chýbajúca hodnota sa zahrnie')
|
|
MAX_ENTITIES: int = Field(default=8, ge=1, le=20)
|
|
|
|
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')
|
|
for field in ['facts', 'paths']:
|
|
if result['status'] == 'ok' and (not isinstance(result.get(field), list) or
|
|
not all(isinstance(row, dict) for row in result[field])):
|
|
raise ValueError('Missing graph evidence')
|
|
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_entities': self.valves.MAX_ENTITIES}
|
|
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_graph(self, query: str) -> str:
|
|
"""Vyhľadaj grafové vzťahy, spoločné atribúty, práce a počty 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: Fakty, grafové cesty, agregácia, zdroje a metadáta alebo 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=(',', ':'))
|