feat: improve WeKnora source preview and wiki management
This commit is contained in:
1 parent
94cf37a2ae
commit
3ab2c9b282
19 files changed
+2602
-303
No files matched your search
@@ -179,6 +179,33 @@ class ManualKnowledgeCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=2_000_000)
|
||||
|
||||
|
||||
class WikiPageCreate(BaseModel):
|
||||
slug: str = Field(..., min_length=1, max_length=512)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(..., min_length=1, max_length=2_000_000)
|
||||
summary: str | None = Field(default=None, max_length=200_000)
|
||||
page_type: str | None = Field(default="article", min_length=1, max_length=64)
|
||||
status: str | None = Field(default="published", min_length=1, max_length=64)
|
||||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||||
parent_slug: str | None = Field(default=None, max_length=512)
|
||||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||||
folder_id: str | None = Field(default=None, max_length=128)
|
||||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class WikiPageUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
content: str | None = Field(default=None, min_length=1, max_length=2_000_000)
|
||||
summary: str | None = Field(default=None, max_length=200_000)
|
||||
page_type: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
status: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||||
parent_slug: str | None = Field(default=None, max_length=512)
|
||||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||||
folder_id: str | None = Field(default=None, max_length=128)
|
||||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class ConversationDepositCreate(BaseModel):
|
||||
thread_id: str = Field(..., min_length=1, max_length=128)
|
||||
question: str = Field(default="", max_length=200_000)
|
||||
@@ -291,15 +318,17 @@ async def _get_weknora_admin_session(runtime: Any, *, force_refresh: bool = Fals
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
f"{runtime.api_base_url}/api/v1/auth/login",
|
||||
json={"email": runtime.admin_email, "password": runtime.admin_password},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.warning("WeKnora admin login timed out: base_url=%s", runtime.api_base_url)
|
||||
raise HTTPException(status_code=504, detail="WeKnora admin login timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora admin login unavailable: base_url=%s error=%r", runtime.api_base_url, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora admin login is unavailable") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
@@ -412,13 +441,14 @@ def _request_kb_id(request: Request, body: Any) -> str:
|
||||
|
||||
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.get(
|
||||
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
|
||||
params=params,
|
||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora verification request failed: base_url=%s path=%s error=%r", runtime.api_base_url, path, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora verification request failed") from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="WeKnora resource was not found")
|
||||
@@ -502,6 +532,18 @@ async def _authorize_weknora_api_proxy(
|
||||
if len(tail) == 1:
|
||||
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
||||
action = tail[1]
|
||||
if read_method and action == "batch":
|
||||
ids = [
|
||||
item.strip()
|
||||
for raw in request.query_params.getlist("ids")
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=403, detail="Batch operation must include document ids")
|
||||
for document_id in ids:
|
||||
await _verify_proxy_document_belongs_to_kb(runtime, ctx, document_id, token)
|
||||
return
|
||||
if action in {"batch", "batch-delete", "batch-reparse", "folder", "move", "tags"}:
|
||||
if _request_kb_id(request, json_body) != ctx.weknora_id:
|
||||
raise HTTPException(status_code=403, detail="Batch operation must be scoped to the current knowledge base")
|
||||
@@ -783,6 +825,7 @@ def _weknora_embed_injection(
|
||||
"tenantId": tenant_id,
|
||||
"user": user,
|
||||
"tenant": tenant,
|
||||
"weknoraId": ctx.weknora_id,
|
||||
"publicPrefix": public_prefix,
|
||||
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
|
||||
}
|
||||
@@ -878,6 +921,29 @@ html.deerflow-weknora-embed [class^="guide__"],
|
||||
html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
display: none !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-hidden="true"] {{
|
||||
display: none !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] {{
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
z-index: 2147483000 !important;
|
||||
background: var(--td-bg-color-page, #fff) !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
}}
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="drawer"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Drawer"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="dialog"],
|
||||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Dialog"] {{
|
||||
max-width: none !important;
|
||||
}}
|
||||
</style>
|
||||
<script id="deerflow-weknora-embed-guard">
|
||||
(() => {{
|
||||
@@ -1019,6 +1085,143 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
}});
|
||||
}}
|
||||
|
||||
const launchParams = new URLSearchParams(location.search);
|
||||
const requestedWikiSlug = launchParams.get("wikiSlug") || launchParams.get("slug") || "";
|
||||
const requestedDocumentId =
|
||||
launchParams.get("documentId") ||
|
||||
launchParams.get("knowledgeId") ||
|
||||
launchParams.get("knowledge_id") ||
|
||||
"";
|
||||
const sourceDetailMode = Boolean(requestedDocumentId || requestedWikiSlug);
|
||||
if (sourceDetailMode) {{
|
||||
root.classList.add("deerflow-source-detail-request");
|
||||
}}
|
||||
let requestedWikiOpened = false;
|
||||
let requestedDocumentOpened = false;
|
||||
let sourceDetailAttempts = 0;
|
||||
const maxSourceDetailAttempts = 18;
|
||||
function escapeAttr(value) {{
|
||||
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
|
||||
return String(value).replace(/["\\\\]/g, "\\\\$&");
|
||||
}}
|
||||
function openRequestedWikiSlug() {{
|
||||
if (!requestedWikiSlug || requestedWikiOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const slug = escapeAttr(requestedWikiSlug);
|
||||
const link =
|
||||
document.querySelector(`[data-slug="${{slug}}"]`) ||
|
||||
document.querySelector(`a[href*="${{encodeURIComponent(requestedWikiSlug)}}"]`) ||
|
||||
document.querySelector(`a[href*="${{requestedWikiSlug.replace(/"/g, "%22")}}"]`);
|
||||
if (!link || typeof link.click !== "function") return;
|
||||
requestedWikiOpened = true;
|
||||
link.click();
|
||||
}}
|
||||
function openRequestedDocumentId() {{
|
||||
if (!requestedDocumentId || requestedDocumentOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const id = escapeAttr(requestedDocumentId);
|
||||
const encoded = encodeURIComponent(requestedDocumentId);
|
||||
const candidates = [
|
||||
`[data-id="${{id}}"]`,
|
||||
`[data-row-key="${{id}}"]`,
|
||||
`[data-knowledge-id="${{id}}"]`,
|
||||
`[data-document-id="${{id}}"]`,
|
||||
`a[href*="${{encoded}}"]`,
|
||||
`button[href*="${{encoded}}"]`
|
||||
];
|
||||
let node = null;
|
||||
for (const selector of candidates) {{
|
||||
node = document.querySelector(selector);
|
||||
if (node) break;
|
||||
}}
|
||||
if (!node) {{
|
||||
node = Array.from(document.querySelectorAll("[data-id],[data-row-key],[data-knowledge-id],[data-document-id]"))
|
||||
.find((item) => {{
|
||||
const values = [
|
||||
item.getAttribute("data-id"),
|
||||
item.getAttribute("data-row-key"),
|
||||
item.getAttribute("data-knowledge-id"),
|
||||
item.getAttribute("data-document-id")
|
||||
];
|
||||
return values.includes(requestedDocumentId);
|
||||
}}) || null;
|
||||
}}
|
||||
if (!node) return;
|
||||
const clickable = node.closest("button,a,[role='button'],.t-card,.document-card,.knowledge-card,.file-card,.t-list-item,.t-table__row") || node;
|
||||
if (!clickable || typeof clickable.click !== "function") return;
|
||||
requestedDocumentOpened = true;
|
||||
clickable.click();
|
||||
}}
|
||||
function sourceDetailCandidateScore(node) {{
|
||||
if (!node || node === document.body || node === document.documentElement) return 0;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 360 || rect.height < 260) return 0;
|
||||
const text = (node.textContent || "").slice(0, 1200);
|
||||
const className = String(node.className || "");
|
||||
let score = 0;
|
||||
if (/基本信息|上传时间|创建时间|摘要|文档内容|查看分块|预览|引用内容|Wiki 原文|全文/.test(text)) score += 4;
|
||||
if (/drawer|Drawer|dialog|Dialog|modal|Modal|detail|Detail|preview|Preview/.test(className)) score += 3;
|
||||
if (rect.right > window.innerWidth * 0.65 && rect.width >= window.innerWidth * 0.38) score += 2;
|
||||
if (getComputedStyle(node).position === "fixed") score += 2;
|
||||
return score;
|
||||
}}
|
||||
function findSourceDetailPanel() {{
|
||||
const selectors = [
|
||||
".t-drawer",
|
||||
".t-drawer__content",
|
||||
".t-dialog",
|
||||
".t-dialog__ctx",
|
||||
".document-detail",
|
||||
".knowledge-detail",
|
||||
".document-preview",
|
||||
"[class*='Drawer']",
|
||||
"[class*='drawer']",
|
||||
"[class*='Dialog']",
|
||||
"[class*='dialog']",
|
||||
"[class*='Detail']",
|
||||
"[class*='detail']",
|
||||
"[class*='Preview']",
|
||||
"[class*='preview']"
|
||||
].join(",");
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
document.querySelectorAll(selectors).forEach((node) => {{
|
||||
const score = sourceDetailCandidateScore(node);
|
||||
if (score > bestScore) {{
|
||||
best = node;
|
||||
bestScore = score;
|
||||
}}
|
||||
}});
|
||||
return bestScore >= 5 ? best : null;
|
||||
}}
|
||||
function isolateSourceDetailPanel() {{
|
||||
if (!sourceDetailMode) return;
|
||||
if (root.classList.contains("deerflow-source-detail-open")) return;
|
||||
const panel = findSourceDetailPanel();
|
||||
if (!panel) return;
|
||||
panel.dataset.deerflowSourceDetailPanel = "true";
|
||||
document.querySelectorAll("[data-deerflow-source-detail-hidden='true']").forEach((node) => {{
|
||||
if (node.contains(panel) || panel.contains(node)) return;
|
||||
delete node.dataset.deerflowSourceDetailHidden;
|
||||
}});
|
||||
Array.from(document.body.children).forEach((child) => {{
|
||||
if (child === panel || child.contains(panel)) return;
|
||||
child.dataset.deerflowSourceDetailHidden = "true";
|
||||
}});
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
}}
|
||||
function tickSourceDetail() {{
|
||||
if (!sourceDetailMode || root.classList.contains("deerflow-source-detail-open")) return true;
|
||||
sourceDetailAttempts += 1;
|
||||
openRequestedDocumentId();
|
||||
openRequestedWikiSlug();
|
||||
isolateSourceDetailPanel();
|
||||
if (root.classList.contains("deerflow-source-detail-open")) return true;
|
||||
if (sourceDetailAttempts >= maxSourceDetailAttempts) {{
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
|
||||
const allowedPath = cfg.allowedPath;
|
||||
function allowedUrl(url) {{
|
||||
try {{
|
||||
@@ -1160,14 +1363,20 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
tickSourceDetail();
|
||||
if (ready) return;
|
||||
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||
ready = true;
|
||||
root.classList.add("deerflow-weknora-ready");
|
||||
}}
|
||||
let observerTimer = 0;
|
||||
const readyObserver = new MutationObserver(() => {{
|
||||
markReady(false);
|
||||
refreshThemeSoon();
|
||||
if (observerTimer) return;
|
||||
observerTimer = window.setTimeout(() => {{
|
||||
observerTimer = 0;
|
||||
markReady(false);
|
||||
refreshThemeSoon();
|
||||
}}, 120);
|
||||
}});
|
||||
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
|
||||
if (document.readyState === "complete") {{
|
||||
@@ -1175,12 +1384,23 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
}} else {{
|
||||
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
|
||||
}}
|
||||
setInterval(() => {{
|
||||
const maintenanceTimer = setInterval(() => {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
if (tickSourceDetail() && sourceDetailMode) {{
|
||||
clearInterval(maintenanceTimer);
|
||||
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||
}}
|
||||
}}, 500);
|
||||
setTimeout(() => markReady(true), 2200);
|
||||
setTimeout(() => {{
|
||||
markReady(true);
|
||||
if (sourceDetailMode && !root.classList.contains("deerflow-source-detail-open")) {{
|
||||
root.classList.add("deerflow-source-detail-open");
|
||||
clearInterval(maintenanceTimer);
|
||||
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||
}}
|
||||
}}, 9000);
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
@@ -1236,7 +1456,7 @@ async def _proxy_weknora_request(
|
||||
|
||||
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -1244,10 +1464,13 @@ async def _proxy_weknora_request(
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
if is_api and response.status_code == 401:
|
||||
if is_api and response.status_code in {401, 403}:
|
||||
session = await _get_weknora_admin_session(runtime, force_refresh=True)
|
||||
headers["Authorization"] = f"Bearer {session.get('token')}"
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
|
||||
tenant_id = _tenant_id_from_session(session)
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -1258,6 +1481,7 @@ async def _proxy_weknora_request(
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("WeKnora proxy request failed: url=%s error=%r", url, exc)
|
||||
raise HTTPException(status_code=502, detail="WeKnora proxy request failed") from exc
|
||||
|
||||
content = response.content
|
||||
@@ -1577,7 +1801,18 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"status": str(page.get("status") or ""),
|
||||
"summary": summary,
|
||||
"content": content,
|
||||
"aliases": list(page.get("aliases") or []) if isinstance(page.get("aliases"), list) else [],
|
||||
"parent_slug": str(page.get("parent_slug") or page.get("parentSlug") or ""),
|
||||
"category_path": list(page.get("category_path") or []) if isinstance(page.get("category_path"), list) else [],
|
||||
"folder_id": str(page.get("folder_id") or page.get("folderId") or ""),
|
||||
"wiki_path": str(page.get("wiki_path") or page.get("wikiPath") or ""),
|
||||
"depth": int(page.get("depth") or 0),
|
||||
"version": int(page.get("version") or 0),
|
||||
"created_at": str(page.get("created_at") or ""),
|
||||
"updated_at": str(page.get("updated_at") or ""),
|
||||
"source_refs": list(page.get("source_refs") or []) if isinstance(page.get("source_refs"), list) else [],
|
||||
"in_links": list(page.get("in_links") or []) if isinstance(page.get("in_links"), list) else [],
|
||||
"out_links": list(page.get("out_links") or []) if isinstance(page.get("out_links"), list) else [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1627,31 +1862,6 @@ async def _find_wiki_page_for_chunk(
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
|
||||
candidate_refs = _candidate_chunk_ref_tokens(focus, chunk_id)
|
||||
query = str(focus.get("knowledge_title") or focus.get("title") or focus.get("file_name") or "").strip()
|
||||
try:
|
||||
page_list = await client.list_wiki_pages(knowledge_base_id, page=1, page_size=100, query=query)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to list WeKnora wiki pages for source preview", exc_info=True)
|
||||
return None
|
||||
|
||||
for page in page_list.get("pages", []):
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
source_refs = _source_ref_tokens(page.get("source_refs"))
|
||||
if not source_refs or not candidate_refs.intersection(source_refs):
|
||||
continue
|
||||
slug = str(page.get("slug") or "").strip()
|
||||
try:
|
||||
full_page = await client.get_wiki_page(knowledge_base_id, slug) if slug else page
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to hydrate WeKnora wiki page slug=%s", slug, exc_info=True)
|
||||
full_page = page
|
||||
view = _wiki_page_view(full_page)
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
return None
|
||||
|
||||
|
||||
@@ -2187,17 +2397,37 @@ async def list_wiki_pages(
|
||||
request: Request,
|
||||
mapping_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=100),
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
query: str = Query(default="", max_length=255),
|
||||
) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||
try:
|
||||
return await _client_or_503().list_wiki_pages(
|
||||
result = await _client_or_503().list_wiki_pages(
|
||||
str(row["weknora_id"]),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query.strip(),
|
||||
)
|
||||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||||
pages = [
|
||||
view
|
||||
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
|
||||
if isinstance(page_item, dict)
|
||||
for view in [_wiki_page_view(page_item)]
|
||||
if view is not None
|
||||
]
|
||||
return {**result, "pages": pages}
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||||
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
payload = body.model_dump(exclude_none=True)
|
||||
try:
|
||||
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -2206,7 +2436,34 @@ async def list_wiki_pages(
|
||||
async def get_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||
try:
|
||||
return await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.patch("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||||
async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: WikiPageUpdate) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
changes = body.model_dump(exclude_none=True)
|
||||
if not changes:
|
||||
try:
|
||||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
try:
|
||||
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
||||
return _wiki_page_view(page) or page
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}", status_code=204)
|
||||
async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
try:
|
||||
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -2407,6 +2664,20 @@ async def get_source_context(
|
||||
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
||||
if sanitize_deposit and wiki_page is not None:
|
||||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
||||
focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {})
|
||||
document_id = str(
|
||||
focus_chunk.get("knowledge_id")
|
||||
or focus_chunk.get("document_id")
|
||||
or focus_chunk.get("knowledgeId")
|
||||
or ""
|
||||
)
|
||||
document_title = str(
|
||||
focus_chunk.get("knowledge_title")
|
||||
or focus_chunk.get("title")
|
||||
or focus_chunk.get("file_name")
|
||||
or focus_chunk.get("filename")
|
||||
or ""
|
||||
)
|
||||
normalized_chunks = [
|
||||
{
|
||||
"id": str(chunk.get("id") or ""),
|
||||
@@ -2426,6 +2697,8 @@ async def get_source_context(
|
||||
"data": {
|
||||
"knowledge_base_id": row["id"],
|
||||
"knowledge_base_name": row.get("name") or "",
|
||||
"document_id": _sanitize_conversation_deposit_value(document_id) if sanitize_deposit else document_id,
|
||||
"document_title": _sanitize_conversation_deposit_value(document_title) if sanitize_deposit else document_title,
|
||||
"focus_chunk_id": chunk_id,
|
||||
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
||||
"wiki_page": wiki_page,
|
||||
|
||||
+37
-1
@@ -110,6 +110,7 @@ class WeKnoraClient:
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
response = await client.post(
|
||||
@@ -164,7 +165,11 @@ class WeKnoraClient:
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
owns_client = self._http_client is None
|
||||
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
for attempt in range(2):
|
||||
response = await client.request(
|
||||
@@ -392,6 +397,7 @@ class WeKnoraClient:
|
||||
client = self._http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout_seconds),
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
for attempt in range(2):
|
||||
@@ -486,6 +492,36 @@ class WeKnoraClient:
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages",
|
||||
json=dict(payload),
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def update_wiki_page(self, knowledge_base_id: str, slug: str, changes: Mapping[str, Any]) -> dict[str, Any]:
|
||||
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
|
||||
value = _data(
|
||||
await self._request(
|
||||
"PUT",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
|
||||
json=dict(changes),
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None:
|
||||
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
|
||||
)
|
||||
|
||||
async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]:
|
||||
ids = [str(value) for value in knowledge_base_ids if str(value)]
|
||||
if not ids:
|
||||
|
||||
+3
@@ -133,6 +133,9 @@ async def llmwiki_search_tool(
|
||||
{
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
||||
"document_id": visible_result.get("knowledge_id") or "",
|
||||
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
|
||||
}
|
||||
)
|
||||
citations.append(
|
||||
|
||||
@@ -551,6 +551,39 @@ async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages()
|
||||
assert pages["pages"][0]["slug"] == "guide"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append((request.method, request.url.path))
|
||||
if request.method == "POST":
|
||||
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
|
||||
assert json.loads(request.content) == {"slug": "folder/guide", "title": "Guide", "content": "# Guide"}
|
||||
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
||||
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"
|
||||
if request.method == "PUT":
|
||||
assert json.loads(request.content) == {"title": "Guide", "content": "# Guide"}
|
||||
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
||||
created = await client.create_wiki_page("kb-one", {"slug": "folder/guide", "title": "Guide", "content": "# Guide"})
|
||||
updated = await client.update_wiki_page("kb-one", "folder/guide", {"title": "Guide", "content": "# Guide"})
|
||||
await client.delete_wiki_page("kb-one", "folder/guide")
|
||||
|
||||
assert created["slug"] == "folder/guide"
|
||||
assert updated["slug"] == "folder/guide"
|
||||
assert seen == [
|
||||
("POST", "/api/v1/knowledgebase/kb-one/wiki/pages"),
|
||||
("PUT", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
||||
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
|
||||
Reference in new issue
Block a user