Open WebUI: способности — веб-поиск, code interpreter, RAG по базе (193 файла, cron-синк) + модель «Ассистент Олега»

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dttb
2026-06-22 06:07:53 +03:00
parent bac7a0992d
commit 0b4a87c1f0
2 changed files with 153 additions and 0 deletions

View File

@@ -39,6 +39,15 @@ tags: [homelab, proxmox, open-webui, omniroute, npm, ai]
- Проверка end-to-end: `POST /api/chat/completions {model:"cc/claude-opus-4-8", messages:[...], stream:false}` → ответ от Opus 4.8.
- `openapi.json` отключён (0 байт) — интроспекции нет, эндпоинты выше зафиксированы вручную.
## Способности (что «умеет»)
Open WebUI = пассивный «кабинет подумать/найти/написать», НЕ исполнитель на инфре (это German/Антошка).
- **Веб-поиск:** DuckDuckGo (без ключа). `POST /api/v1/retrieval/config/update {web:{ENABLE_WEB_SEARCH:true, WEB_SEARCH_ENGINE:"duckduckgo", ...}}`.
- **Code interpreter:** включён из коробки (Pyodide, в браузере). `GET/POST /api/v1/configs/code_execution`.
- **База знаний (RAG):** коллекция `7f60313d-add9-4f99-ad53-89e792295129` «DTTB Knowledge Base», embedding локальный (MiniLM). **193 файла**.
- Синк: `/opt/owui-kb-sync/sync.py` (инкрементальный по md5) + `run.sh` (flock + `git pull` + sync), **cron `*/20`** на LXC 142. Vault клонирован из Gitea (`http://oleg:***@10.0.0.189:3000/oleg/knowledge-base.git`).
- Синкаются только `projects/decisions/claude-memory/snippets` (~200), **минус** credential-файлы (vault на 1435 .md, 1152 = `notes/` шум). Пустые и дубль-контент (Open WebUI дедупит по хешу → `DUPLICATE_CONTENT`/`EMPTY_CONTENT` 400) скрипт ловит и помечает skip в манифесте, чтоб не ретраить.
- **Модель `oleg-assistant` «Ассистент Олега»** = base `cc/claude-opus-4-8` + привязанная коллекция (`meta.knowledge`). Выбираешь её → RAG включается сам, ответы с цитатами. Проверено end-to-end. (Дефолт остался plain `cc/claude-opus-4-8` для общих вопросов; KB-модель — по выбору.)
## Команды
```bash
# контейнер

144
snippets/owui-kb-sync.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Инкрементальный синк vault → Open WebUI Knowledge.
Синкает только полезные папки, исключает файлы с секретами. Идемпотентен (по md5).
Устойчив: пустые файлы и ошибки отдельных файлов не валят весь прогон.
Запуск по cron на LXC 142. Логи: /opt/owui-kb-sync/sync.log
"""
import os, sys, json, hashlib, re, urllib.request, urllib.error, uuid
BASE = "http://localhost:3000"
EMAIL, PASSWORD = "it5870@yandex.ru", "1qaz!QAZ"
KB_ID = "7f60313d-add9-4f99-ad53-89e792295129"
ROOT = "/opt/owui-kb-sync/kb"
MANIFEST = "/opt/owui-kb-sync/manifest.json"
INCLUDE_DIRS = ("projects", "decisions", "claude-memory", "snippets")
EXCLUDE_SUBSTR = ("credential", "secret", ".env", "/password") # путь в нижнем регистре
_FM = re.compile(r"^---\n.*?\n---\n", re.S)
def _req(path, data=None, token=None, method=None, raw=None, ctype="application/json", timeout=180):
hdr = {}
if token:
hdr["Authorization"] = "Bearer " + token
if raw is not None:
body, hdr["Content-Type"] = raw, ctype
elif data is not None:
body, hdr["Content-Type"] = json.dumps(data).encode(), ctype
else:
body = None
r = urllib.request.Request(BASE + path, data=body, headers=hdr,
method=method or ("POST" if body else "GET"))
with urllib.request.urlopen(r, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def login():
return _req("/api/v1/auths/signin", {"email": EMAIL, "password": PASSWORD})["token"]
def has_text(data):
"""Есть ли осмысленный текст помимо YAML-фронтматтера."""
try:
t = data.decode("utf-8", "ignore")
except Exception:
return False
t = _FM.sub("", t).strip()
return len(t) >= 5
def upload_file(token, relpath, content_bytes):
boundary = "----owui" + uuid.uuid4().hex
body = b"".join([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{relpath}"\r\n'.encode(),
b"Content-Type: text/markdown\r\n\r\n",
content_bytes,
f"\r\n--{boundary}--\r\n".encode(),
])
return _req("/api/v1/files/", raw=body, token=token,
ctype=f"multipart/form-data; boundary={boundary}")
def kb_add(token, file_id):
return _req(f"/api/v1/knowledge/{KB_ID}/file/add", {"file_id": file_id}, token=token)
def safe(fn, *a):
try:
fn(*a)
except Exception as e:
print(" warn:", e)
def kb_remove(token, file_id):
if not file_id:
return
safe(lambda: _req(f"/api/v1/knowledge/{KB_ID}/file/remove", {"file_id": file_id}, token=token))
def file_delete(token, file_id):
if not file_id:
return
safe(lambda: _req(f"/api/v1/files/{file_id}", token=token, method="DELETE"))
def wanted(relpath):
low = "/" + relpath.lower()
return (relpath.startswith(INCLUDE_DIRS) and relpath.endswith(".md")
and not any(s in low for s in EXCLUDE_SUBSTR))
def main():
token = login()
manifest = json.load(open(MANIFEST)) if os.path.exists(MANIFEST) else {}
current = {}
skipped_empty = 0
for dirpath, _, files in os.walk(ROOT):
for f in files:
rel = os.path.relpath(os.path.join(dirpath, f), ROOT)
if not wanted(rel):
continue
data = open(os.path.join(dirpath, f), "rb").read()
if not has_text(data):
skipped_empty += 1
continue
current[rel] = (hashlib.md5(data).hexdigest(), data)
added = changed = removed = errors = 0
for rel, (h, data) in current.items():
old = manifest.get(rel)
if old and old["hash"] == h:
continue
try:
if old:
kb_remove(token, old["file_id"])
file_delete(token, old["file_id"])
fid = upload_file(token, rel, data)["id"]
kb_add(token, fid)
manifest[rel] = {"hash": h, "file_id": fid}
changed += 1 if old else 0
added += 0 if old else 1
except urllib.error.HTTPError as e:
errors += 1
if e.code == 400: # дубликат/пустой контент — близнец уже в коллекции, не ретраить
manifest[rel] = {"hash": h, "file_id": None, "skip": True}
print(f" ERR {rel}: HTTP {e.code}")
except Exception as e:
errors += 1
print(f" ERR {rel}: {e}")
for rel in list(manifest):
if rel not in current:
kb_remove(token, manifest[rel]["file_id"])
file_delete(token, manifest[rel]["file_id"])
del manifest[rel]
removed += 1
json.dump(manifest, open(MANIFEST, "w"), ensure_ascii=False)
print(f"sync done: +{added} ~{changed} -{removed} | пропущено пустых: {skipped_empty} | "
f"ошибок: {errors} | в коллекции: {len(manifest)}")
if __name__ == "__main__":
main()