dp-zp-agent/packages/openwebui/graphrag/tool.py
2026-10-26 23:43:10 +01:00

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=(',', ':'))