Open WebUI: способности — веб-поиск, code interpreter, RAG по базе (193 файла, cron-синк) + модель «Ассистент Олега»
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
144
snippets/owui-kb-sync.py
Normal 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()
|
||||
Reference in New Issue
Block a user