Balíky nástrojov
This commit is contained in:
parent
aaa0a65f93
commit
ed403e7490
124
README.md
124
README.md
@ -272,9 +272,15 @@ zp-agent/
|
|||||||
│ ├── test_configuration.py
|
│ ├── test_configuration.py
|
||||||
│ └── test_security.py
|
│ └── test_security.py
|
||||||
│
|
│
|
||||||
|
├── packages/
|
||||||
|
│ └── openwebui/
|
||||||
|
│ ├── rag/tool.py
|
||||||
|
│ └── graphrag/tool.py
|
||||||
|
│
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── start_project.sh
|
│ ├── start_project.sh
|
||||||
│ ├── compose.sh
|
│ ├── compose.sh
|
||||||
|
│ ├── build_tool_packages.py
|
||||||
│ └── verify_project.py
|
│ └── verify_project.py
|
||||||
│
|
│
|
||||||
├── data/
|
├── data/
|
||||||
@ -295,6 +301,9 @@ zp-agent/
|
|||||||
`retrieval/` zdieľa dátový kontrakt, publikačnú politiku a delenie podľa rozpočtu.
|
`retrieval/` zdieľa dátový kontrakt, publikačnú politiku a delenie podľa rozpočtu.
|
||||||
`runtime/` pripravuje model/tokenizér a kontroluje štart a dostupnosť služieb.
|
`runtime/` pripravuje model/tokenizér a kontroluje štart a dostupnosť služieb.
|
||||||
`sync/` spravuje Git, frontu a spoločné publikovanie SQLite/Neo4j indexov.
|
`sync/` spravuje Git, frontu a spoločné publikovanie SQLite/Neo4j indexov.
|
||||||
|
`packages/openwebui/` obsahuje samostatne inštalovateľné Tools. Každý
|
||||||
|
`tool.py` potrebuje iba štandardnú knižnicu a Pydantic dostupný v OpenWebUI;
|
||||||
|
neimportuje backend ani druhý balík. Je to klient existujúceho API.
|
||||||
V koreňovom `tools/` zostáva iba `__init__.py`.
|
V koreňovom `tools/` zostáva iba `__init__.py`.
|
||||||
|
|
||||||
Na Gite je z dokumentácie iba tento hlavný README. Vedľajšie návody,
|
Na Gite je z dokumentácie iba tento hlavný README. Vedľajšie návody,
|
||||||
@ -1048,6 +1057,110 @@ Výsledná odpoveď má byť grounded v ZP Wiki a má používať relevantný
|
|||||||
`source_url`. Táto pôvodná cesta s voľným následným generovaním sama
|
`source_url`. Táto pôvodná cesta s voľným následným generovaním sama
|
||||||
nezaručuje kontroly doručenia pripraveného Pipe.
|
nezaručuje kontroly doručenia pripraveného Pipe.
|
||||||
|
|
||||||
|
## Samostatné RAG a GraphRAG Tools pre OpenWebUI
|
||||||
|
|
||||||
|
| Nástroj | Zdroj na Gite | Funkcia | Backend |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ZP Wiki RAG | [rag/tool.py](packages/openwebui/rag/tool.py) | `retrieve_zpwiki_context(query)` | `POST /rag` |
|
||||||
|
| ZP Wiki GraphRAG | [graphrag/tool.py](packages/openwebui/graphrag/tool.py) | `retrieve_zpwiki_graph(query)` | `POST /graphrag` |
|
||||||
|
|
||||||
|
Oba sú natívne **Tools**, ktoré model môže zavolať počas odpovedania.
|
||||||
|
Pipe opísaný vyššie je samostatná funkcia na riadené doručenie odpovede.
|
||||||
|
Nový autorizovaný `/graphrag` používa rovnaký API kľúč a chybový kontrakt
|
||||||
|
ako `/rag`. Zostáva mimo pôvodnej OpenAPI schémy, ktorú existujúci RAG
|
||||||
|
klient používa bez zmeny.
|
||||||
|
|
||||||
|
### Vytvorenie inštalovateľných balíkov
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/build_tool_packages.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Vzniknú `data/tool_packages/zpwiki_rag.json`, `zpwiki_graphrag.json`
|
||||||
|
a príslušné ZIP archívy. JSON je zoznam objektov vo formáte importu
|
||||||
|
Tools v OpenWebUI 0.9.4: ID, názov, Python kód, metadáta a prázdne
|
||||||
|
prístupové granty. Neobsahuje kľúče ani používateľské údaje.
|
||||||
|
ZIP obsahuje tento importný JSON ako `openwebui-tool.json`, `tool.py`,
|
||||||
|
`config.example.json`, `valves.schema.json`, návod `INSTALL.txt` a manifest
|
||||||
|
SHA-256 jednotlivých súborov. Opakovaný build v rovnakom prostredí je
|
||||||
|
deterministický. Zdrojové súbory a builder sú na Gite; vygenerované
|
||||||
|
archívy, importné JSON a kontrolné výstupy ostávajú ignorované v `data/`.
|
||||||
|
|
||||||
|
### Inštalácia každého nástroja
|
||||||
|
|
||||||
|
1. V OpenWebUI otvorte **Workspace → Tools → Import** a vyberte
|
||||||
|
`zpwiki_rag.json` alebo `zpwiki_graphrag.json`. ZIP sa najprv rozbaľuje;
|
||||||
|
samotný ZIP ani `.py` nie sú súbory pre toto importné tlačidlo.
|
||||||
|
2. Alternatívne cez **Create** vložte celý obsah príslušného `tool.py`.
|
||||||
|
Pri načítaní z Gitu otvorte súbor v repozitári, kliknite na **Raw**
|
||||||
|
a použite túto URL cez import/načítanie z URL. Každý nástroj sa
|
||||||
|
inštaluje samostatne; backendový projekt netreba inštalovať do OpenWebUI.
|
||||||
|
3. Vo **Valves** daného nástroja nastavte hodnoty z tabuľky nižšie.
|
||||||
|
4. Nástroj zapnite v novom chate alebo ho pripojte k vlastnému modelu.
|
||||||
|
Použite model s podporou volania nástrojov. Pre RAG skúste otázku na
|
||||||
|
text alebo názov práce; pre GraphRAG porovnanie osôb a rokov.
|
||||||
|
5. Prístup sprístupnite iba požadovaným účtom. Importné balíky neurčujú
|
||||||
|
verejné zdieľanie; granty sa nastavujú až v školskom UI.
|
||||||
|
|
||||||
|
Import vyžaduje povolenie Tools Import. Možnosť vytvárať kód a meniť
|
||||||
|
Valves závisí od práv účtu. Na školskom UI sa zatiaľ nič neinštaluje;
|
||||||
|
čaká sa na profesora a sieťové sprístupnenie backendu. Postupy zodpovedajú
|
||||||
|
[oficiálnej dokumentácii Tools](https://docs.openwebui.com/features/extensibility/plugin/tools/).
|
||||||
|
|
||||||
|
### Konfigurácia Valves
|
||||||
|
|
||||||
|
| Nastavenie | RAG | GraphRAG |
|
||||||
|
|---|---|---|
|
||||||
|
| `BACKEND_URL` | Adresa API bez `/rag` | Rovnaká adresa API bez `/graphrag` |
|
||||||
|
| `BACKEND_API_KEY` | Hodnota `SEARCH_API_KEY` | Rovnaký vyhľadávací kľúč |
|
||||||
|
| `TIMEOUT_SECONDS` | Predvolene 30, rozsah 1–120 | Predvolene 30, rozsah 1–120 |
|
||||||
|
| `LIMIT` | Predvolene 5, rozsah 1–20 | Predvolene 40, rozsah 1–100 |
|
||||||
|
| `PUBLISHED_ONLY` | Predvolene true | Predvolene true |
|
||||||
|
| `MAX_PER_DOCUMENT` | Predvolene 1, rozsah 0–10 | Nepoužíva sa |
|
||||||
|
| `MAX_ENTITIES` | Nepoužíva sa | Predvolene 8, rozsah 1–20 |
|
||||||
|
|
||||||
|
Volanie zachová celú otázku a upraví iba vonkajšie medzery. Limit otázky
|
||||||
|
je 500 znakov. Kľúč nie je parametrom nástroja pre model ani súčasťou
|
||||||
|
importného JSON; nastavuje sa iba vo Valves. Nástroje samy negenerujú
|
||||||
|
odpovede a nepotrebujú kľúč k jazykovému modelu.
|
||||||
|
|
||||||
|
HTTP požiadavky idú zo servera OpenWebUI. `localhost` preto neoznačuje
|
||||||
|
vývojový počítač. V spoločnej Docker sieti môže byť adresa
|
||||||
|
`http://zp-agent-api:8000`; pri školskom serveri treba dostupnú adresu
|
||||||
|
dohodnutú s administrátorom. Cesty s prefixom reverzného proxy sú podporované.
|
||||||
|
`PUBLISHED_ONLY=true` skryje iba explicitné `published=false`; chýbajúca
|
||||||
|
hodnota sa zahrnie. Hodnota `MAX_PER_DOCUMENT=0` ruší toto obmedzenie.
|
||||||
|
|
||||||
|
### Výstup, chyby a kontroly
|
||||||
|
|
||||||
|
Výstup je JSON so stavom, dôvodom, zdrojmi, verziou dát a úplnosťou.
|
||||||
|
RAG prenáša úsporný kontext; GraphRAG fakty, cesty, obmedzenia a agregáciu.
|
||||||
|
Podklady sa v klientovi potichu neorezávajú ani neprepisujú. Zachovajú sa
|
||||||
|
aj `no_answer` a `ambiguous`; chyba spojenia sa nevydáva za prázdny výsledok.
|
||||||
|
HTTP chyby, timeout, neplatná konfigurácia, neplatný kontrakt a odpoveď
|
||||||
|
väčšia ako 2 MiB vracajú stav `error` bez čiastočných dôkazov. Presmerovania
|
||||||
|
sa neakceptujú, aby sa kľúč neposlal na inú adresu. Diagnostika a raw chyby
|
||||||
|
nie sú vstupom modelu.
|
||||||
|
|
||||||
|
`output_kind=retrieval_context` a `requires_final_answer_verification=true`
|
||||||
|
označujú, že nejde o schválený text odpovede. Priame zapnutie Tools pri
|
||||||
|
bežnom LLM nezaručuje faktickú správnosť jeho následnej parafrázy.
|
||||||
|
Existujúci Pipe a `/answer` naďalej vykonávajú kontrolu a renderer;
|
||||||
|
ich `accepted_for_delivery` sa nesmie obísť. Hlavný agent, ktorý bude
|
||||||
|
nástroje automaticky vyberať a riadiť finálne doručenie, je ďalší blok.
|
||||||
|
|
||||||
|
Lokálne testy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest -q test/test_openwebui_packages.py
|
||||||
|
.venv/bin/python scripts/verify_project.py --git-network
|
||||||
|
```
|
||||||
|
|
||||||
|
Testy používajú skutočný dočasný HTTP server na loopback rozhraní a
|
||||||
|
FastAPI s riadenými dátami. Overujú autorizáciu, zachovanie otázky,
|
||||||
|
zdrojov a úplnosti, chyby a timeouty, zákaz presmerovaní aj import JSON/ZIP
|
||||||
|
v izolovanom interpreteri bez backendového projektu. Externý LLM sa nevolá.
|
||||||
|
|
||||||
## Retrieval evaluácia
|
## Retrieval evaluácia
|
||||||
|
|
||||||
Frozen retrieval benchmark sa spúšťa napríklad:
|
Frozen retrieval benchmark sa spúšťa napríklad:
|
||||||
@ -1257,13 +1370,14 @@ Kompletná lokálna kontrola s bežiacim projektom a `.env`:
|
|||||||
.venv/bin/python scripts/verify_project.py --git-network
|
.venv/bin/python scripts/verify_project.py --git-network
|
||||||
```
|
```
|
||||||
|
|
||||||
Skript skompiluje všetky Python súbory v aplikácii, nástrojoch, testoch
|
Skript skompiluje všetky Python súbory v aplikácii, nástrojoch, balíkoch, testoch
|
||||||
a skriptoch, overí interné importy a syntax shell skriptov, Git,
|
a skriptoch, overí interné importy a syntax shell skriptov, Git,
|
||||||
Compose, API, spoločnú verziu indexov, integritu SQLite a kompletnú
|
Compose, API, spoločnú verziu indexov, integritu SQLite a kompletnú
|
||||||
regresiu vrátane lokálneho Neo4j. Prepínač `--git-network` navyše iba
|
regresiu vrátane lokálneho Neo4j. Prepínač `--git-network` navyše iba
|
||||||
načíta hlavičku nastavenej wiki vetvy cez existujúce SSH pripojenie.
|
načíta hlavičku nastavenej wiki vetvy cez existujúce SSH pripojenie.
|
||||||
Počas pytestu sú externé spojenia zablokované a kľúč generujúceho modelu
|
Počas pytestu sú externé spojenia zablokované a kľúč generujúceho modelu
|
||||||
odstránený. Výsledky a log ostávajú lokálne v `data/`.
|
odstránený. HTTP transport testy povoľujú iba svoj dočasný loopback port.
|
||||||
|
Výsledky a log ostávajú lokálne v `data/`.
|
||||||
|
|
||||||
Kontrola syntaxe najdôležitejších upravovaných modulov:
|
Kontrola syntaxe najdôležitejších upravovaných modulov:
|
||||||
|
|
||||||
@ -1325,8 +1439,10 @@ nové otázky; opakované meranie odhalených otázok je regresia.
|
|||||||
|
|
||||||
### 3. OpenWebUI nástroje
|
### 3. OpenWebUI nástroje
|
||||||
|
|
||||||
Pripraviť samostatne inštalovateľný RAG nástroj a GraphRAG nástroj.
|
Samostatné zdroje sú v `packages/openwebui/rag/` a `graphrag/`.
|
||||||
Každý komponent bude mať vlastný adresár, rozhranie a testy.
|
Builder vytvára importné JSON a ZIP s konfiguráciou, schémou a odtlačkami;
|
||||||
|
komunikácia s backendom má lokálne HTTP testy. Inštalácia na školskom
|
||||||
|
OpenWebUI čaká na oprávnenia a dostupnosť backendu.
|
||||||
|
|
||||||
### 4. Hlavný agent
|
### 4. Hlavný agent
|
||||||
|
|
||||||
|
|||||||
@ -99,7 +99,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
async def retrieval_http_error(request: Request, error: StarletteHTTPException):
|
async def retrieval_http_error(request: Request, error: StarletteHTTPException):
|
||||||
if request.url.path not in {'/rag', '/search'}:
|
if request.url.path not in {'/rag', '/search', '/graphrag'}:
|
||||||
return await http_exception_handler(request, error)
|
return await http_exception_handler(request, error)
|
||||||
reason = {400: 'invalid_query', 401: 'unauthorized', 403: 'forbidden',
|
reason = {400: 'invalid_query', 401: 'unauthorized', 403: 'forbidden',
|
||||||
404: 'not_found', 405: 'method_not_allowed', 422: 'invalid_request',
|
404: 'not_found', 405: 'method_not_allowed', 422: 'invalid_request',
|
||||||
@ -110,7 +110,7 @@ async def retrieval_http_error(request: Request, error: StarletteHTTPException):
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def retrieval_validation_error(request: Request, error: RequestValidationError):
|
async def retrieval_validation_error(request: Request, error: RequestValidationError):
|
||||||
if request.url.path not in {'/rag', '/search'}:
|
if request.url.path not in {'/rag', '/search', '/graphrag'}:
|
||||||
return await request_validation_exception_handler(request, error)
|
return await request_validation_exception_handler(request, error)
|
||||||
# Retain useful validation locations/types without echoing submitted secrets.
|
# Retain useful validation locations/types without echoing submitted secrets.
|
||||||
details = [{'loc': row['loc'], 'type': row['type'], 'msg': row['msg']} for row in error.errors()]
|
details = [{'loc': row['loc'], 'type': row['type'], 'msg': row['msg']} for row in error.errors()]
|
||||||
@ -159,7 +159,7 @@ async def add_security_headers(
|
|||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
if request.url.path not in {'/rag', '/search'}:
|
if request.url.path not in {'/rag', '/search', '/graphrag'}:
|
||||||
raise
|
raise
|
||||||
# Serialization failures must obey the same contract as backend errors.
|
# Serialization failures must obey the same contract as backend errors.
|
||||||
logger.exception('Neočakávaná chyba výstupu retrieval API.')
|
logger.exception('Neočakávaná chyba výstupu retrieval API.')
|
||||||
|
|||||||
@ -42,11 +42,12 @@ from tools.rag.common import (
|
|||||||
)
|
)
|
||||||
from tools.rag.rag_utils import build_rag_context
|
from tools.rag.rag_utils import build_rag_context
|
||||||
from tools.rag.llm_context import compact_rag_payload
|
from tools.rag.llm_context import compact_rag_payload
|
||||||
|
from tools.graphrag.graph_search import search_graph, DEFAULT_LIMIT, DEFAULT_MAX_ENTITIES
|
||||||
from tools.rag.search_utils import search_database
|
from tools.rag.search_utils import search_database
|
||||||
from tools.sync.wiki_git import expected_branch
|
from tools.sync.wiki_git import expected_branch
|
||||||
from tools.retrieval.publication import DEFAULT_PUBLISHED_ONLY
|
from tools.retrieval.publication import DEFAULT_PUBLISHED_ONLY
|
||||||
from tools.runtime.tool_health import tools_readiness
|
from tools.runtime.tool_health import tools_readiness
|
||||||
from tools.retrieval.retrieval_contract import RetrievalResponse, RetrievalSource, RetrievalErrorResponse
|
from tools.retrieval.retrieval_contract import RetrievalResponse, RetrievalSource, RetrievalErrorResponse, error_reason
|
||||||
from tools.answers.answer_delivery import deliver_answer
|
from tools.answers.answer_delivery import deliver_answer
|
||||||
|
|
||||||
|
|
||||||
@ -166,6 +167,13 @@ class SyncRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GraphRequest(StrictRequestModel):
|
||||||
|
query: str = Field(min_length=1, max_length=500)
|
||||||
|
limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=100)
|
||||||
|
max_entities: int = Field(default=DEFAULT_MAX_ENTITIES, ge=1, le=20)
|
||||||
|
published_only: bool = DEFAULT_PUBLISHED_ONLY
|
||||||
|
|
||||||
|
|
||||||
class AnswerRequest(StrictRequestModel):
|
class AnswerRequest(StrictRequestModel):
|
||||||
query: str = Field(min_length=1, max_length=500)
|
query: str = Field(min_length=1, max_length=500)
|
||||||
engine: Literal['rag', 'graphrag'] = 'rag'
|
engine: Literal['rag', 'graphrag'] = 'rag'
|
||||||
@ -329,14 +337,7 @@ def raise_retrieval_error(
|
|||||||
),
|
),
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
if isinstance(
|
if error_reason(error) == 'retrieval_unavailable':
|
||||||
error,
|
|
||||||
(
|
|
||||||
FileNotFoundError,
|
|
||||||
sqlite3.Error,
|
|
||||||
RuntimeError,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Retrieval nie je dostupný."
|
"Retrieval nie je dostupný."
|
||||||
)
|
)
|
||||||
@ -615,6 +616,16 @@ def rag(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/graphrag', dependencies=[Depends(require_search_api_key)], include_in_schema=False)
|
||||||
|
def graph_retrieval(request: GraphRequest) -> dict[str, Any]:
|
||||||
|
"""Native GraphRAG Tool transport; the legacy OpenAPI schema stays stable."""
|
||||||
|
try:
|
||||||
|
return search_graph(request.query, limit=request.limit, max_entities=request.max_entities,
|
||||||
|
published_only=request.published_only)
|
||||||
|
except Exception as error:
|
||||||
|
raise_retrieval_error(error)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/search",
|
"/search",
|
||||||
dependencies=[
|
dependencies=[
|
||||||
|
|||||||
143
packages/openwebui/graphrag/tool.py
Normal file
143
packages/openwebui/graphrag/tool.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
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=(',', ':'))
|
||||||
141
packages/openwebui/rag/tool.py
Normal file
141
packages/openwebui/rag/tool.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
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=(',', ':'))
|
||||||
87
scripts/build_tool_packages.py
Normal file
87
scripts/build_tool_packages.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build standalone OpenWebUI Tools archives; generated files stay in data/."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ENGINES = ('rag', 'graphrag')
|
||||||
|
|
||||||
|
|
||||||
|
def json_bytes(value) -> bytes:
|
||||||
|
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + '\n').encode()
|
||||||
|
|
||||||
|
|
||||||
|
def build_package(engine: str, output: Path) -> dict:
|
||||||
|
if engine not in ENGINES:
|
||||||
|
raise ValueError('Unknown tool package')
|
||||||
|
source = ROOT / 'packages/openwebui' / engine / 'tool.py'
|
||||||
|
content = source.read_bytes()
|
||||||
|
header = ast.get_docstring(ast.parse(content.decode()))
|
||||||
|
metadata = dict(line.strip().split(': ', 1) for line in header.splitlines() if ': ' in line)
|
||||||
|
name = 'zpwiki_package_' + engine
|
||||||
|
spec = importlib.util.spec_from_file_location(name, source)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
config = module.Tools.Valves().model_dump()
|
||||||
|
schema = module.Tools.Valves.model_json_schema()
|
||||||
|
finally:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
instructions = (
|
||||||
|
f'ZP Wiki {engine.upper()} – OpenWebUI Tool\n\n'
|
||||||
|
'ZIP najprv rozbal. Vo Workspace > Tools > Import vyber openwebui-tool.json.\n'
|
||||||
|
'Alebo cez Create vytvor nástroj a vlož celý obsah tool.py do editora.\n'
|
||||||
|
'Pri importe cez URL použi raw URL súboru tool.py, nie ZIP ani stránku repozitára.\n'
|
||||||
|
'Vo Valves nastav BACKEND_URL a BACKEND_API_KEY = SEARCH_API_KEY backendu.\n'
|
||||||
|
'Adresa musí byť dostupná zo servera OpenWebUI; neobsahuje koncový endpoint.\n'
|
||||||
|
'Backend aj oba nástroje používajú rovnaký dátový kontrakt.\n'
|
||||||
|
'Výstup sú vyhľadané podklady, nie schválený finálny text.\n'
|
||||||
|
'Pre kontrolované doručenie používaj existujúci Pipe a /answer.\n'
|
||||||
|
'Žiadny ďalší nástroj ani lokálny Python balík zp-agent nie je potrebný.\n'
|
||||||
|
'Podrobný postup, testy a obmedzenia sú v hlavnom README projektu.\n')
|
||||||
|
import_form = [{'id': module.TOOL_ID, 'name': metadata['title'], 'content': content.decode(),
|
||||||
|
'meta': {'description': metadata['description'], 'manifest': metadata},
|
||||||
|
'access_grants': []}]
|
||||||
|
files = {'tool.py': content, 'openwebui-tool.json': json_bytes(import_form), 'config.example.json': json_bytes(config),
|
||||||
|
'valves.schema.json': json_bytes(schema), 'INSTALL.txt': instructions.encode()}
|
||||||
|
manifest = {'schema_version': 1, 'kind': 'openwebui_tool', 'package_id': module.TOOL_ID,
|
||||||
|
'version': metadata['version'], 'minimum_openwebui_version': metadata['required_open_webui_version'],
|
||||||
|
'backend_endpoint': module.ENDPOINT, 'entrypoint': 'tool.py', 'output_kind': 'retrieval_context',
|
||||||
|
'files': {name: hashlib.sha256(data).hexdigest() for name, data in files.items()}}
|
||||||
|
files['manifest.json'] = json_bytes(manifest)
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination = output / (module.TOOL_ID + '.zip')
|
||||||
|
import_file = output / (module.TOOL_ID + '.json')
|
||||||
|
import_file.write_bytes(files['openwebui-tool.json'])
|
||||||
|
# Fixed timestamps and sorted entries make a rebuild byte-for-byte identical.
|
||||||
|
with zipfile.ZipFile(destination, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, data in sorted(files.items()):
|
||||||
|
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
info.create_system = 3
|
||||||
|
info.external_attr = 0o100644 << 16
|
||||||
|
archive.writestr(info, data)
|
||||||
|
return {'package_id': module.TOOL_ID, 'archive': str(destination), 'import_file': str(import_file),
|
||||||
|
'sha256': hashlib.sha256(destination.read_bytes()).hexdigest(), 'manifest': manifest}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--output', type=Path, default=ROOT / 'data/tool_packages')
|
||||||
|
args = parser.parse_args()
|
||||||
|
built = [build_package(engine, args.output) for engine in ENGINES]
|
||||||
|
(args.output / 'packages.json').write_bytes(json_bytes(built))
|
||||||
|
print(json.dumps([{key: row[key] for key in ['package_id', 'archive', 'import_file', 'sha256']} for row in built], ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ast
|
import ast
|
||||||
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@ -15,7 +16,9 @@ import sqlite3
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import types
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@ -34,9 +37,14 @@ def local_test_worker() -> int:
|
|||||||
original_connect, original_connect_ex = socket.socket.connect, socket.socket.connect_ex
|
original_connect, original_connect_ex = socket.socket.connect, socket.socket.connect_ex
|
||||||
original_dns = socket.getaddrinfo
|
original_dns = socket.getaddrinfo
|
||||||
|
|
||||||
|
def local_ports():
|
||||||
|
# HTTP transport tests register only their ephemeral loopback listener.
|
||||||
|
extra = os.getenv('ZP_TEST_HTTP_PORT')
|
||||||
|
return {7687, 8000} | ({int(extra)} if extra else set())
|
||||||
|
|
||||||
def permitted(address):
|
def permitted(address):
|
||||||
try:
|
try:
|
||||||
return address[1] in {7687, 8000} and ipaddress.ip_address(address[0]).is_loopback
|
return address[1] in local_ports() and ipaddress.ip_address(address[0]).is_loopback
|
||||||
except (ValueError, TypeError, IndexError):
|
except (ValueError, TypeError, IndexError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -51,7 +59,7 @@ def local_test_worker() -> int:
|
|||||||
return original_connect_ex(self, address)
|
return original_connect_ex(self, address)
|
||||||
|
|
||||||
def guarded_dns(host, port, *args, **kwargs):
|
def guarded_dns(host, port, *args, **kwargs):
|
||||||
if host not in {'localhost', '127.0.0.1', '::1'} or int(port) not in {7687, 8000}:
|
if host not in {'localhost', '127.0.0.1', '::1'} or int(port) not in local_ports():
|
||||||
raise RuntimeError('External DNS disabled during local verification')
|
raise RuntimeError('External DNS disabled during local verification')
|
||||||
return original_dns(host, port, *args, **kwargs)
|
return original_dns(host, port, *args, **kwargs)
|
||||||
|
|
||||||
@ -62,7 +70,7 @@ def local_test_worker() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def verify_code() -> dict:
|
def verify_code() -> dict:
|
||||||
files = [path for name in ['app', 'tools', 'test', 'scripts'] for path in (ROOT / name).rglob('*.py')]
|
files = [path for name in ['app', 'tools', 'packages', 'test', 'scripts'] for path in (ROOT / name).rglob('*.py')]
|
||||||
for path in files:
|
for path in files:
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
compile(text, str(path), 'exec')
|
compile(text, str(path), 'exec')
|
||||||
@ -152,6 +160,53 @@ def main() -> int:
|
|||||||
"assert p.returncode==0,'Wiki origin unavailable'; print(p.stdout.strip())")
|
"assert p.returncode==0,'Wiki origin unavailable'; print(p.stdout.strip())")
|
||||||
check('wiki_git_ssh', lambda: command(*compose, 'exec', '-T', 'zp-agent-api', 'python', '-c', probe))
|
check('wiki_git_ssh', lambda: command(*compose, 'exec', '-T', 'zp-agent-api', 'python', '-c', probe))
|
||||||
|
|
||||||
|
def tool_packages():
|
||||||
|
from scripts.build_tool_packages import build_package
|
||||||
|
key = os.getenv('SEARCH_API_KEY', '').strip()
|
||||||
|
if not key:
|
||||||
|
for line in (ROOT / '.env').read_text().splitlines():
|
||||||
|
name, separator, value = line.partition('=')
|
||||||
|
if separator and name == 'SEARCH_API_KEY':
|
||||||
|
key = shlex.split(value)[0]
|
||||||
|
assert key, 'Search API key is missing'
|
||||||
|
built = []
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
graph_tool = None
|
||||||
|
versions = []
|
||||||
|
for engine in ('rag', 'graphrag'):
|
||||||
|
package = build_package(engine, ROOT / 'data/tool_packages')
|
||||||
|
with zipfile.ZipFile(package['archive']) as archive:
|
||||||
|
form = json.loads(archive.read('openwebui-tool.json'))[0]
|
||||||
|
module = types.ModuleType('verify_packaged_' + engine)
|
||||||
|
sys.modules[module.__name__] = module
|
||||||
|
try:
|
||||||
|
exec(compile(form['content'], '<packaged-tool>', 'exec'), module.__dict__)
|
||||||
|
tool = module.Tools()
|
||||||
|
tool.valves.BACKEND_URL = 'http://127.0.0.1:8000'
|
||||||
|
tool.valves.BACKEND_API_KEY = key
|
||||||
|
method = getattr(tool, 'retrieve_zpwiki_context' if engine == 'rag' else 'retrieve_zpwiki_graph')
|
||||||
|
result = json.loads(await method('Aký je názov diplomovej práce osoby Ján Holp?'))
|
||||||
|
assert result['status'] == 'ok' and result['sources'], 'Packaged tool cannot retrieve evidence'
|
||||||
|
assert result['requires_final_answer_verification'] is True
|
||||||
|
versions.append(result['index_version'])
|
||||||
|
if engine == 'graphrag':
|
||||||
|
graph_tool = tool
|
||||||
|
built.append({name: package[name] for name in ['package_id', 'archive', 'sha256']})
|
||||||
|
finally:
|
||||||
|
sys.modules.pop(module.__name__, None)
|
||||||
|
assert versions[0] == versions[1], 'Tools use different data versions'
|
||||||
|
negative = json.loads(await graph_tool.retrieve_zpwiki_graph(
|
||||||
|
'Aký je názov diplomovej práce osoby Ján Holp v roku 1999?'))
|
||||||
|
assert negative['status'] == 'no_answer' and not negative['facts']
|
||||||
|
graph_tool.valves.BACKEND_API_KEY = 'invalid-test-key'
|
||||||
|
assert json.loads(await graph_tool.retrieve_zpwiki_graph('Práca?'))['reason'] == 'unauthorized'
|
||||||
|
asyncio.run(run())
|
||||||
|
return {'packages': built, 'live_retrieval': True, 'same_data_version': True,
|
||||||
|
'negative_query': True, 'authorization': True, 'external_llm_calls': False}
|
||||||
|
|
||||||
|
check('openwebui_tool_packages', tool_packages)
|
||||||
|
|
||||||
def tests():
|
def tests():
|
||||||
log = args.output.with_suffix('.pytest.log')
|
log = args.output.with_suffix('.pytest.log')
|
||||||
environment = {**os.environ}
|
environment = {**os.environ}
|
||||||
|
|||||||
@ -84,6 +84,7 @@ def test_all_application_python_files_compile() -> None:
|
|||||||
targets = [
|
targets = [
|
||||||
PROJECT_ROOT / "app",
|
PROJECT_ROOT / "app",
|
||||||
PROJECT_ROOT / "tools",
|
PROJECT_ROOT / "tools",
|
||||||
|
PROJECT_ROOT / "packages",
|
||||||
PROJECT_ROOT / "test",
|
PROJECT_ROOT / "test",
|
||||||
PROJECT_ROOT / "scripts",
|
PROJECT_ROOT / "scripts",
|
||||||
]
|
]
|
||||||
|
|||||||
283
test/test_openwebui_packages.py
Normal file
283
test/test_openwebui_packages.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
"""Standalone installation and real loopback HTTP transport, without an LLM."""
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from neo4j.exceptions import ServiceUnavailable
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
import app.routes as routes
|
||||||
|
from scripts.build_tool_packages import build_package
|
||||||
|
|
||||||
|
KEY = 'a' * 64
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_tool(engine, path=None):
|
||||||
|
path = path or ROOT / 'packages/openwebui' / engine / 'tool.py'
|
||||||
|
spec = importlib.util.spec_from_file_location('standalone_' + engine, path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=['rag', 'graphrag'])
|
||||||
|
def engine(request):
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def evidence():
|
||||||
|
return {'status': 'ok', 'reason': None, 'complete': False, 'truncated': True,
|
||||||
|
'truncation_reasons': ['result_limit'], 'index_version': {'version': 'fixture'},
|
||||||
|
'sources': [{'source_id': 'S1', 'document_path': 'pages/profile/README.md',
|
||||||
|
'source_url': 'https://example.test/profile', 'title': 'Eva Nová'}],
|
||||||
|
'context': 'Doložený text práce.', 'facts': [{'person': 'Eva Nová', 'year': 2024}],
|
||||||
|
'paths': [{'nodes': ['Eva Nová', 'Práca'], 'relationships': ['AUTHORED']}],
|
||||||
|
'aggregation': {'count': 1, 'complete': True}, 'constraints': {'years': [2024]}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def backend(monkeypatch, evidence):
|
||||||
|
monkeypatch.setattr(main, 'embed_query', lambda _: None)
|
||||||
|
calls = []
|
||||||
|
def retrieved(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return copy.deepcopy(evidence)
|
||||||
|
def rag_retrieved(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
result = {key: copy.deepcopy(value) for key, value in evidence.items()
|
||||||
|
if key in routes.RagResponse.model_fields}
|
||||||
|
return {**result, 'query': args[1], 'engine': 'hybrid_fts5_embeddings',
|
||||||
|
'source_count': len(evidence['sources']), 'strategies': ['fixture'],
|
||||||
|
'instructions': [], 'answer_format': {}}
|
||||||
|
monkeypatch.setattr(routes, 'build_rag_context', rag_retrieved)
|
||||||
|
monkeypatch.setattr(routes, 'compact_rag_payload', lambda result, **kwargs: result)
|
||||||
|
monkeypatch.setattr(routes, 'search_graph', retrieved)
|
||||||
|
state = {'requests': [], 'mode': 'api', 'code': 200, 'raw': b''}
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
def do_POST(self):
|
||||||
|
body = self.rfile.read(int(self.headers.get('Content-Length', '0')))
|
||||||
|
state['requests'].append({'path': self.path, 'body': json.loads(body),
|
||||||
|
'authorization': self.headers.get('Authorization')})
|
||||||
|
if state['mode'] == 'timeout':
|
||||||
|
time.sleep(1.25)
|
||||||
|
if state['mode'] == 'disconnect':
|
||||||
|
self.close_connection = True
|
||||||
|
return
|
||||||
|
if state['mode'] == 'api':
|
||||||
|
response = client.post(self.path.removeprefix('/prefix'), content=body,
|
||||||
|
headers=dict(self.headers))
|
||||||
|
code, data = response.status_code, response.content
|
||||||
|
else:
|
||||||
|
code, data = state['code'], state['raw']
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
if code == 302:
|
||||||
|
self.send_header('Location', state['base'] + '/redirected')
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
self.wfile.write(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
monkeypatch.setenv('ZP_TEST_HTTP_PORT', str(server.server_port))
|
||||||
|
state['base'] = f'http://127.0.0.1:{server.server_port}'
|
||||||
|
thread = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield state, calls
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def configured(engine, backend, path=None):
|
||||||
|
module = load_tool(engine, path)
|
||||||
|
tool = module.Tools()
|
||||||
|
tool.valves.BACKEND_URL = backend[0]['base'] + '/prefix/'
|
||||||
|
tool.valves.BACKEND_API_KEY = KEY
|
||||||
|
return module, tool
|
||||||
|
|
||||||
|
|
||||||
|
def invoke(tool, query='Akú prácu má Eva Nová v roku 2024?'):
|
||||||
|
methods = [getattr(tool, name) for name in dir(tool) if name.startswith('retrieve_zpwiki_')]
|
||||||
|
assert len(methods) == 1
|
||||||
|
return json.loads(asyncio.run(methods[0](query)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_actual_http_auth_query_and_all_evidence_survive(engine, backend, evidence):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
query = 'Akú prácu má Eva Nová v roku 2024?'
|
||||||
|
result = invoke(tool, query)
|
||||||
|
request = backend[0]['requests'][0]
|
||||||
|
assert request['path'] == '/prefix/' + engine
|
||||||
|
assert request['authorization'] == 'Bearer ' + KEY
|
||||||
|
assert request['body']['query'] == query and request['body']['published_only'] is True
|
||||||
|
assert result['status'] == 'ok'
|
||||||
|
for key in ['sources', 'index_version', 'complete', 'truncated', 'truncation_reasons']:
|
||||||
|
assert result[key] == evidence[key]
|
||||||
|
assert result['requires_final_answer_verification'] is True
|
||||||
|
if engine == 'rag':
|
||||||
|
assert result['context'] == evidence['context']
|
||||||
|
assert request['body']['include_diagnostics'] is False
|
||||||
|
else:
|
||||||
|
assert result['facts'] == evidence['facts'] and result['paths'] == evidence['paths']
|
||||||
|
assert result['aggregation'] == evidence['aggregation']
|
||||||
|
assert backend[1][0][1] == {'limit': 40, 'max_entities': 8, 'published_only': True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_controls_scope_without_mutating_question(engine, backend):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
tool.valves.LIMIT = 2
|
||||||
|
tool.valves.PUBLISHED_ONLY = False
|
||||||
|
invoke(tool, ' Osoba, rok a tag ')
|
||||||
|
body = backend[0]['requests'][0]['body']
|
||||||
|
assert body['limit'] == 2 and body['published_only'] is False
|
||||||
|
assert body['query'] == 'Osoba, rok a tag'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('status', ['no_answer', 'ambiguous'])
|
||||||
|
def test_no_answer_and_ambiguity_are_not_relabelled_as_positive(engine, backend, evidence, status):
|
||||||
|
evidence.update(status=status, reason='no_matching_facts', sources=[], facts=[], paths=[], context='')
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
result = invoke(tool)
|
||||||
|
assert result['status'] == status and result['reason'] == 'no_matching_facts'
|
||||||
|
assert result['sources'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('query', ['', ' ', 'x' * 501, None])
|
||||||
|
def test_invalid_query_does_not_make_a_network_request(engine, backend, query):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
assert invoke(tool, query)['reason'] == 'invalid_query'
|
||||||
|
assert backend[0]['requests'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', ['', 'file:///tmp/file', 'https://user:password@example.test',
|
||||||
|
'http://example.test?query=1', 'http://example.test/#fragment'])
|
||||||
|
def test_invalid_configuration_never_sends_credentials(engine, backend, url):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
tool.valves.BACKEND_URL = url
|
||||||
|
assert invoke(tool)['reason'] == 'invalid_configuration'
|
||||||
|
assert backend[0]['requests'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('code,reason', [(401, 'unauthorized'), (403, 'forbidden'),
|
||||||
|
(404, 'backend_endpoint_not_found'), (429, 'backend_busy'), (503, 'retrieval_unavailable'),
|
||||||
|
(500, 'backend_http_error'), (302, 'redirect_not_allowed')])
|
||||||
|
def test_http_errors_do_not_expose_body_or_follow_redirect(engine, backend, code, reason):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
backend[0].update(mode='raw', code=code, raw=(KEY + ' PRIVATE_ERROR').encode())
|
||||||
|
result = invoke(tool)
|
||||||
|
assert result['status'] == 'error' and result['reason'] == reason
|
||||||
|
assert result['http_status'] == code and result['sources'] == []
|
||||||
|
assert KEY not in json.dumps(result) and 'PRIVATE_ERROR' not in json.dumps(result)
|
||||||
|
assert len(backend[0]['requests']) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_unauthorized_backend_rejects_wrong_key(engine, backend):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
tool.valves.BACKEND_API_KEY = 'wrong'
|
||||||
|
assert invoke(tool)['reason'] == 'unauthorized'
|
||||||
|
assert backend[1] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('raw', [b'<html>not JSON</html>', b'[]', b'{"status":"ok"}'])
|
||||||
|
def test_invalid_backend_data_fails_closed(engine, backend, raw):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
backend[0].update(mode='raw', raw=raw)
|
||||||
|
assert invoke(tool)['reason'] == 'invalid_backend_response'
|
||||||
|
|
||||||
|
|
||||||
|
def test_contradictory_completeness_is_rejected(engine, backend, evidence):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
evidence.update(complete=True, truncated=True)
|
||||||
|
assert invoke(tool)['reason'] == 'invalid_backend_response'
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_and_unverified_answer_are_not_passed_to_model(engine, backend, evidence):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
evidence.update(diagnostics={'private': 'PRIVATE'}, answer='UNVERIFIED', accepted_for_delivery=True)
|
||||||
|
backend[0].update(mode='raw', raw=json.dumps(evidence).encode())
|
||||||
|
result = invoke(tool)
|
||||||
|
assert 'diagnostics' not in result and 'answer' not in result and 'accepted_for_delivery' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_responses_are_rejected_without_partial_evidence(engine, backend, monkeypatch):
|
||||||
|
module, tool = configured(engine, backend)
|
||||||
|
monkeypatch.setattr(module, 'MAX_RESPONSE_BYTES', 100)
|
||||||
|
backend[0].update(mode='raw', raw=b'x' * 101)
|
||||||
|
result = invoke(tool)
|
||||||
|
assert result['reason'] == 'response_too_large' and result['sources'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_is_reported_without_raw_exception(engine, backend):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
tool.valves.TIMEOUT_SECONDS = 1
|
||||||
|
backend[0].update(mode='timeout')
|
||||||
|
assert invoke(tool)['reason'] == 'backend_timeout'
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_closed_before_response_returns_safe_error(engine, backend):
|
||||||
|
_, tool = configured(engine, backend)
|
||||||
|
backend[0]['mode'] = 'disconnect'
|
||||||
|
assert invoke(tool)['reason'] == 'backend_unavailable'
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_serialization_errors_have_shared_contract(backend, monkeypatch):
|
||||||
|
monkeypatch.setattr(routes, 'search_graph', lambda *args, **kwargs: {'invalid': object()})
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
response = client.post('/graphrag', json={'query': 'Práca?'}, headers={'X-API-Key': KEY})
|
||||||
|
assert response.status_code == 500 and response.json()['reason'] == 'internal_error'
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_api_uses_shared_error_contract_and_validates_query(backend, monkeypatch):
|
||||||
|
def unavailable(*args, **kwargs):
|
||||||
|
raise ServiceUnavailable('PRIVATE_DATABASE_ERROR')
|
||||||
|
monkeypatch.setattr(routes, 'search_graph', unavailable)
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
response = client.post('/graphrag', json={'query': 'Práca?'}, headers={'X-API-Key': KEY})
|
||||||
|
assert response.status_code == 503 and response.json()['reason'] == 'retrieval_unavailable'
|
||||||
|
assert 'PRIVATE_DATABASE_ERROR' not in response.text
|
||||||
|
response = client.post('/graphrag', json={'query': ' ', 'limit': 0}, headers={'X-API-Key': KEY})
|
||||||
|
assert response.status_code == 422 and response.json()['status'] == 'error'
|
||||||
|
assert client.post('/graphrag', json={'query': 'Práca?'}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_and_json_installation_are_standalone_and_reproducible(engine, backend, tmp_path):
|
||||||
|
built = build_package(engine, tmp_path / 'first')
|
||||||
|
again = build_package(engine, tmp_path / 'again')
|
||||||
|
assert built['sha256'] == again['sha256']
|
||||||
|
with zipfile.ZipFile(built['archive']) as archive:
|
||||||
|
manifest = json.loads(archive.read('manifest.json'))
|
||||||
|
for name, digest in manifest['files'].items():
|
||||||
|
assert hashlib.sha256(archive.read(name)).hexdigest() == digest
|
||||||
|
form = json.loads(archive.read('openwebui-tool.json'))
|
||||||
|
assert len(form) == 1 and form[0]['id'] == 'zpwiki_' + engine
|
||||||
|
assert form[0]['access_grants'] == []
|
||||||
|
assert form[0]['content'].encode() == archive.read('tool.py')
|
||||||
|
assert Path(built['import_file']).read_bytes() == archive.read('openwebui-tool.json')
|
||||||
|
extracted = tmp_path / 'isolated.py'
|
||||||
|
extracted.write_bytes(archive.read('tool.py'))
|
||||||
|
code = "import pathlib; source=pathlib.Path(__import__('sys').argv[1]).read_text(); scope={'__name__':'standalone_tool'}; exec(source,scope); tool=scope['Tools'](); assert len([n for n in dir(tool) if n.startswith('retrieve_zpwiki_')])==1"
|
||||||
|
result = subprocess.run([sys.executable, '-I', '-c', code, str(extracted)],
|
||||||
|
cwd=tmp_path, capture_output=True, text=True, timeout=20)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
_, tool = configured(engine, backend, extracted)
|
||||||
|
assert invoke(tool)['status'] == 'ok'
|
||||||
Loading…
Reference in New Issue
Block a user