Feat: Add knowledge compilation workflows (#16515)

## Summary
- Add knowledge compilation template APIs, services, and builtin
template seed data
- Add advanced knowledge compile structure/artifact/RAPTOR workflow
support
- Update parsing, dataset/document APIs, and supporting services for
compilation workflows
This commit is contained in:
Kevin Hu
2026-07-02 23:22:07 +08:00
committed by GitHub
parent 7d64a78f83
commit 62f94cd59b
57 changed files with 14587 additions and 3094 deletions

View File

@@ -16,6 +16,7 @@
import base64
import binascii
import datetime
import json
import logging
import re
@@ -54,6 +55,7 @@ from api.utils.reference_metadata_utils import (
)
from common import settings
from common.constants import LLMType, ParserType, RetCode, TaskStatus
from common.doc_store.doc_store_base import OrderByExpr
from common.metadata_utils import convert_conditions, meta_filter
from common.misc_utils import thread_pool_exec
from common.string_utils import is_content_empty, remove_redundant_spaces
@@ -135,6 +137,19 @@ def _map_doc(doc):
return renamed_doc
def _get_query_id_list(args, name: str) -> list[str]:
values = args.getlist(name) if hasattr(args, "getlist") else [args.get(name)]
ids: list[str] = []
seen: set[str] = set()
for value in values:
for item in str(value or "").split(","):
item = item.strip()
if item and item not in seen:
ids.append(item)
seen.add(item)
return ids
def _strip_chunk_runtime_fields(chunk):
for name in [name for name in chunk.keys() if re.search(r"(_vec$|_sm_|_tks|_ltks)", name)]:
del chunk[name]
@@ -148,6 +163,15 @@ def _get_dataset_tenant_id(dataset_id):
return kb.tenant_id
def _compilation_template_kind(kind) -> str:
if not isinstance(kind, str):
return ""
normalized = kind.strip().lower().replace("-", "_")
if normalized in {"pageindex", "page_index", "knowledge_graph"}:
return "timeline"
return normalized
def _resolve_reference_metadata(req: dict, search_config: dict | None = None):
return resolve_reference_metadata_preferences(req, search_config)
@@ -169,7 +193,9 @@ async def parse(tenant_id, dataset_id):
if not e:
return get_error_data_result(message=f"You don't own the dataset {dataset_id}.")
if kb.pipeline_id:
return get_error_data_result(message="Datasets configured with an ingestion pipeline cannot be parsed with `/datasets/{dataset_id}/chunks`. Use `/documents/ingest` instead.", code=RetCode.ARGUMENT_ERROR)
return get_error_data_result(
message="Datasets configured with an ingestion pipeline cannot be parsed with `/datasets/{dataset_id}/chunks`. Use `/documents/ingest` instead.", code=RetCode.ARGUMENT_ERROR
)
req = await get_request_json()
if not req.get("document_ids"):
return get_error_data_result("`document_ids` is required")
@@ -363,9 +389,19 @@ async def retrieval_test(tenant_id):
question += await keyword_extraction(LLMBundle(kb.tenant_id, chat_model_config), question)
ranks = await settings.retriever.retrieval(
question, embd_mdl, tenant_ids, kb_ids, page, size, similarity_threshold,
vector_similarity_weight, top, doc_ids, rerank_mdl=rerank_mdl,
highlight=highlight, rank_feature=label_question(question, kbs),
question,
embd_mdl,
tenant_ids,
kb_ids,
page,
size,
similarity_threshold,
vector_similarity_weight,
top,
doc_ids,
rerank_mdl=rerank_mdl,
highlight=highlight,
rank_feature=label_question(question, kbs),
)
if toc_enhance:
chat_model_config = get_tenant_default_model_by_type(kb.tenant_id, LLMType.CHAT)
@@ -421,13 +457,17 @@ async def list_chunks(tenant_id, dataset_id, document_id):
page = int(req.get("page", 1))
size = validate_rest_api_page_size(int(req.get("page_size", 30)))
question = req.get("keywords", "")
chunk_ids = _get_query_id_list(req, "chunk_ids")
query = {
"doc_ids": [document_id],
"page": page,
"size": size,
"question": question,
"sort": True,
"must_not": {"exists": "compile_kwd"},
}
if chunk_ids:
query["id"] = chunk_ids
if "available" in req:
query["available_int"] = 1 if req["available"] == "true" else 0
@@ -438,6 +478,8 @@ async def list_chunks(tenant_id, dataset_id, document_id):
return get_result(message=f"Chunk not found: {dataset_id}/{req.get('id')}", code=RetCode.DATA_ERROR)
if str(chunk.get("doc_id", chunk.get("document_id"))) != str(document_id):
return get_result(message=f"Chunk not found: {dataset_id}/{req.get('id')}", code=RetCode.DATA_ERROR)
if chunk.get("compile_kwd"):
return get_result(message=f"Chunk not found: {dataset_id}/{req.get('id')}", code=RetCode.DATA_ERROR)
_strip_chunk_runtime_fields(chunk)
res["total"] = 1
final_chunk = {
@@ -468,11 +510,7 @@ async def list_chunks(tenant_id, dataset_id, document_id):
for chunk_id in sres.ids:
d = {
"id": chunk_id,
"content": (
remove_redundant_spaces(sres.highlight[chunk_id])
if question and chunk_id in sres.highlight
else sres.field[chunk_id].get("content_with_weight", "")
),
"content": (remove_redundant_spaces(sres.highlight[chunk_id]) if question and chunk_id in sres.highlight else sres.field[chunk_id].get("content_with_weight", "")),
"document_id": sres.field[chunk_id]["doc_id"],
"docnm_kwd": sres.field[chunk_id]["docnm_kwd"],
"important_keywords": sres.field[chunk_id].get("important_kwd", []),
@@ -506,6 +544,8 @@ async def get_chunk(tenant_id, dataset_id, document_id, chunk_id):
chunk = settings.docStoreConn.get(chunk_id, search.index_name(dataset_tenant_id), [dataset_id])
if chunk is None or str(chunk.get("doc_id", chunk.get("document_id"))) != str(document_id):
return get_result(data=False, message="Chunk not found!", code=RetCode.DATA_ERROR)
if chunk.get("compile_kwd"):
return get_result(data=False, message="Chunk not found!", code=RetCode.DATA_ERROR)
return get_result(data=_strip_chunk_runtime_fields(chunk))
except Exception as e:
if str(e).find("NotFoundError") >= 0:
@@ -513,6 +553,292 @@ async def get_chunk(tenant_id, dataset_id, document_id, chunk_id):
return server_error_response(e)
@manager.route("/datasets/<dataset_id>/documents/<document_id>/structure/graph", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def get_document_structure_graph(tenant_id, dataset_id, document_id):
"""Return per-template structure graphs for a document.
Response shape::
{
"templates": [
{
"template_id": "<id> | 'legacy:<compile_kwd>'",
"template_name": "<display name>",
"kind": "list | set | hypergraph | timeline | page_index | …",
"entities": [...],
"relations": [...]
},
...
]
}
Rows that pre-date the ``compilation_template_ids`` stamp are surfaced
under a synthetic ``legacy:<compile_kwd>`` bucket so an in-flight
migration doesn't drop their data on the floor. Empty templates
(zero entities AND zero relations) are filtered out.
"""
from rag.nlp import search
from api.db.services.compilation_template_group_service import CompilationTemplateGroupService
if not KnowledgebaseService.accessible(kb_id=dataset_id, user_id=tenant_id):
return get_error_data_result(message=f"You don't own the dataset {dataset_id}.")
dataset_tenant_id = _get_dataset_tenant_id(dataset_id)
if not dataset_tenant_id:
return get_error_data_result(message=f"You don't own the dataset {dataset_id}.")
docs = DocumentService.query(id=document_id, kb_id=dataset_id)
if not docs:
return get_error_data_result(message=f"You don't own the document {document_id}.")
# Resolve the doc's configured template group → child template ids
# so we can render tabs in the order the user picked them.
# Artifacts-kind templates render on the dataset Artifact tab, not
# here, so they're filtered out.
parser_config = docs[0].parser_config or {}
def _group_ids(raw) -> list[str]:
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
return []
ids: list[str] = []
seen: set[str] = set()
for gid in raw:
if not isinstance(gid, str):
continue
gid = gid.strip()
if gid and gid not in seen:
seen.add(gid)
ids.append(gid)
return ids
group_ids: list[str] = []
if isinstance(parser_config, dict):
if "compilation_template_group_id" in parser_config:
group_ids = _group_ids(parser_config.get("compilation_template_group_id"))
elif isinstance(parser_config.get("ext"), dict):
group_ids = _group_ids(parser_config["ext"].get("compilation_template_group_id"))
configured_ids: list[str] = []
seen_configured_ids: set[str] = set()
template_meta: dict[str, dict] = {}
template_meta_by_kind: dict[str, list[dict]] = {}
for group_id in group_ids:
group = CompilationTemplateGroupService.get_saved(group_id, tenant_id)
if not group:
continue
for template in group.get("templates") or []:
if not isinstance(template, dict):
continue
template_id = str(template.get("id") or "").strip()
if not template_id or template_id in seen_configured_ids:
continue
config = template.get("config") if isinstance(template.get("config"), dict) else {}
raw_kind = (config.get("kind") if isinstance(config, dict) else "") or template.get("kind") or ""
kind_norm = _compilation_template_kind(raw_kind)
if kind_norm == "artifacts":
continue
seen_configured_ids.add(template_id)
configured_ids.append(template_id)
meta = {
"template_id": template_id,
"template_name": template.get("name") or template_id,
"kind": raw_kind or kind_norm,
"kind_norm": kind_norm,
}
template_meta[template_id] = meta
template_meta_by_kind.setdefault(kind_norm, []).append(meta)
# Load every graph row for this doc in one shot. Each row corresponds
# to one (compile_kwd, template_id) tuple — written by
# ``_struct_upsert_graph_json``.
index_name = search.index_name(dataset_tenant_id)
fields = [
"content_with_weight",
"compile_kwd",
"compilation_template_ids",
"compilation_template_kind_kwd",
]
try:
res = await thread_pool_exec(
settings.docStoreConn.search,
fields,
[],
{"doc_id": [document_id], "knowledge_graph_kwd": ["graph"]},
[],
OrderByExpr(),
0,
1000,
index_name,
[dataset_id],
)
rows = settings.docStoreConn.get_fields(res, fields)
# The RAPTOR graph row is identified by ``compile_kwd``
# alone — it intentionally doesn't carry ``knowledge_graph_kwd``
# (which belongs to the KG feature). Query it separately and
# union into the same bucket map below.
res_raptor = await thread_pool_exec(
settings.docStoreConn.search,
fields,
[],
{"doc_id": [document_id], "compile_kwd": ["raptor_graph"]},
[],
OrderByExpr(),
0,
16,
index_name,
[dataset_id],
)
raptor_rows = settings.docStoreConn.get_fields(res_raptor, fields)
except Exception as e:
return server_error_response(e)
# Merge the two field-maps so the grouping loop below treats them
# identically. Raptor rows clobber by id, which is fine — both
# sources produce stable per-row ids.
if raptor_rows:
rows = dict(rows or {})
rows.update(raptor_rows)
def _row_template_id(row: dict) -> str | None:
raw = row.get("compilation_template_ids")
if isinstance(raw, list):
for v in raw:
if isinstance(v, str) and v.strip():
return v.strip()
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
# Group: template_id → {entities, relations, kind}
grouped: dict[str, dict] = {}
for row in (rows or {}).values():
graph = {}
try:
graph = json.loads(row.get("content_with_weight") or "{}")
except Exception:
continue
if not isinstance(graph, dict):
continue
entities = graph.get("entities") or []
relations = graph.get("relations") or []
if not entities and not relations:
continue
tid = _row_template_id(row)
compile_kwd_val = row.get("compile_kwd") or ""
kind_val = row.get("compilation_template_kind_kwd") or compile_kwd_val
# The RAPTOR graph row has no ``compilation_template_ids`` (it
# isn't derived from a user-authored template). Treat it as its
# own first-class bucket, not a legacy fallback.
is_raptor = compile_kwd_val == "raptor_graph"
if tid:
bucket_id = tid
row_kind_norm = _compilation_template_kind(kind_val)
meta = template_meta.get(bucket_id)
if not meta:
kind_matches = template_meta_by_kind.get(row_kind_norm) or []
if len(kind_matches) == 1:
meta = kind_matches[0]
bucket_name = (meta or {}).get("template_name") or bucket_id
bucket_kind = (meta or {}).get("kind") or kind_val
elif is_raptor:
bucket_id = "raptor"
bucket_name = "RAPTOR Summary"
bucket_kind = "raptor"
else:
# Legacy row: synthesize a stable id keyed by compile_kwd so
# multiple legacy kinds (e.g. ``list`` + ``hypergraph``) on
# the same doc surface as separate tabs.
bucket_id = f"legacy:{compile_kwd_val}"
bucket_name = f"Legacy ({compile_kwd_val})"
bucket_kind = kind_val
if bucket_id not in grouped:
grouped[bucket_id] = {
"template_id": bucket_id,
"template_name": bucket_name,
"kind": bucket_kind,
"entities": [],
"relations": [],
}
grouped[bucket_id]["entities"].extend(entities)
grouped[bucket_id]["relations"].extend(relations)
# Order: configured templates first (in the user's chosen order),
# then any legacy buckets after.
ordered_ids: list[str] = []
for tid in configured_ids:
if tid in grouped and tid not in ordered_ids:
ordered_ids.append(tid)
for bucket_id in grouped.keys():
if bucket_id not in ordered_ids:
ordered_ids.append(bucket_id)
templates_out = [grouped[bid] for bid in ordered_ids if grouped[bid]["entities"] or grouped[bid]["relations"]]
return get_result(data={"templates": templates_out})
@manager.route("/datasets/<dataset_id>/documents/<document_id>/structure/graph", methods=["DELETE"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def delete_document_structure_graph(tenant_id, dataset_id, document_id):
"""Delete one structure-graph tab for a document.
Request body::
{"template_id": "<template id> | legacy:<compile_kwd> | raptor"}
Template-backed structure tabs remove both the compact graph row and
the underlying entity/relation rows. RAPTOR only removes the graph
projection row so summary chunks remain available for retrieval.
"""
from rag.nlp import search
if not KnowledgebaseService.accessible(kb_id=dataset_id, user_id=tenant_id):
return get_error_data_result(message=f"You don't own the dataset {dataset_id}.")
dataset_tenant_id = _get_dataset_tenant_id(dataset_id)
if not dataset_tenant_id:
return get_error_data_result(message=f"You don't own the dataset {dataset_id}.")
docs = DocumentService.query(id=document_id, kb_id=dataset_id)
if not docs:
return get_error_data_result(message=f"You don't own the document {document_id}.")
req = await get_request_json()
template_id = str(req.get("template_id") or "").strip()
if not template_id:
return get_error_data_result(message="`template_id` is required")
index_name = search.index_name(dataset_tenant_id)
def _delete(condition: dict) -> int:
return settings.docStoreConn.delete(condition, index_name, dataset_id)
try:
deleted = 0
if template_id == "raptor":
deleted += _delete({"doc_id": [document_id], "compile_kwd": ["raptor_graph"]})
return get_result(data={"deleted": deleted}, message=f"deleted {deleted} structure graph rows")
if template_id.startswith("legacy:"):
compile_kwd = template_id[len("legacy:") :].strip()
if not compile_kwd:
return get_error_data_result(message="`template_id` is invalid")
base_condition = {"doc_id": [document_id], "compile_kwd": [compile_kwd]}
else:
base_condition = {"doc_id": [document_id], "compilation_template_ids": [template_id]}
deleted += _delete({**base_condition, "knowledge_graph_kwd": ["graph"]})
deleted += _delete({**base_condition, "knowledge_graph_kwd": ["entity", "relation"]})
return get_result(data={"deleted": deleted}, message=f"deleted {deleted} structure graph rows")
except Exception as e:
return server_error_response(e)
@manager.route("/datasets/<dataset_id>/documents/<document_id>/chunks", methods=["POST"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
@@ -625,7 +951,11 @@ async def rm_chunk(tenant_id, dataset_id, document_id):
if req.get("delete_all") is True:
doc = docs[0]
DocumentService.delete_chunk_images(doc, dataset_tenant_id)
chunk_number = settings.docStoreConn.delete({"doc_id": document_id}, search.index_name(dataset_tenant_id), dataset_id)
chunk_number = settings.docStoreConn.delete(
{"doc_id": document_id, "must_not": {"exists": "compile_kwd"}},
search.index_name(dataset_tenant_id),
dataset_id,
)
if chunk_number != 0:
DocumentService.decrement_chunk_num(document_id, dataset_id, 1, chunk_number, 0)
return get_result(message=f"deleted {chunk_number} chunks")
@@ -633,7 +963,7 @@ async def rm_chunk(tenant_id, dataset_id, document_id):
unique_chunk_ids, duplicate_messages = check_duplicate_ids(chunk_ids, "chunk")
chunk_number = settings.docStoreConn.delete(
{"doc_id": document_id, "id": unique_chunk_ids},
{"doc_id": document_id, "id": unique_chunk_ids, "must_not": {"exists": "compile_kwd"}},
search.index_name(dataset_tenant_id),
dataset_id,
)
@@ -758,6 +1088,7 @@ async def switch_chunks(tenant_id, dataset_id, document_id):
available_int = int(req["available_int"]) if "available_int" in req else (1 if req.get("available") else 0)
try:
def _switch_sync():
e, doc = DocumentService.get_by_id(document_id)
if not e:

View File

@@ -0,0 +1,54 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from quart import Response
from api.apps import current_user, login_required
from api.apps.restful_apis.utils.compilation_template_validation import validate_template_payload
from api.db.services.compilation_template_service import CompilationTemplateService
from api.utils.api_utils import get_json_result, server_error_response
_validate_template_payload = validate_template_payload
@manager.route("/compilation_templates/builtins", methods=["GET"]) # noqa: F821
@login_required
def list_builtin_templates() -> Response:
"""Built-in template palette — used as the per-child pre-fill in the
"Add template group" panel. Groups themselves are always user-created;
no builtin groups exist.
"""
try:
templates = CompilationTemplateService.list_builtins()
if not templates:
CompilationTemplateService.seed_builtins_from_files()
templates = CompilationTemplateService.list_builtins()
if not templates:
templates = [
{
"id": template["id"],
"kind": template["kind"],
"display_name": template["name"],
"description": template.get("description", ""),
"config": template["config"],
}
for template in CompilationTemplateService.load_builtins_from_files()
]
templates = CompilationTemplateService.fill_default_llm_for_templates(templates, current_user.id)
return get_json_result(data=templates)
except Exception as exc:
return server_error_response(exc)

View File

@@ -0,0 +1,172 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from quart import Response, request
from api.apps import current_user, login_required
from api.apps.restful_apis.utils.compilation_template_validation import validate_template_payload
from api.db.services.compilation_template_group_service import (
CompilationTemplateGroupService,
GroupValidationError,
)
from api.utils.api_utils import (
get_data_error_result,
get_json_result,
get_request_json,
server_error_response,
validate_request,
)
from api.utils.pagination_utils import validate_rest_api_page_size
_GROUP_NAME_MAX = 128
_GROUP_DESCRIPTION_MAX = 1024
def _validate_group_payload(req: dict, require_all: bool = True) -> str:
if require_all:
for key in ("name", "templates"):
if key not in req:
return f"Missing required field: {key}."
name = req.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
return "Invalid template group name."
if len(name.encode("utf-8")) > _GROUP_NAME_MAX:
return "Template group name is too long."
description = req.get("description")
if description is not None and (not isinstance(description, str) or len(description) > _GROUP_DESCRIPTION_MAX):
return "Invalid template group description."
templates = req.get("templates")
if templates is not None:
if not isinstance(templates, list) or not templates:
return "A template group must contain at least one template."
for child in templates:
if not isinstance(child, dict):
return "Invalid template entry in group."
err = validate_template_payload(child, require_all=True)
if err:
return err
return ""
@manager.route("/compilation_template_groups", methods=["GET"]) # noqa: F821
@login_required
def list_groups() -> Response:
keywords = request.args.get("keywords", "")
scope = request.args.get("scope", "")
page_number = int(request.args.get("page", 0))
items_per_page = validate_rest_api_page_size(int(request.args.get("page_size", 0)))
orderby = request.args.get("orderby", "create_time")
desc = request.args.get("desc", "true").lower() != "false"
try:
groups = CompilationTemplateGroupService.list_saved(current_user.id, keywords, scope, orderby, desc)
total = len(groups)
if page_number and items_per_page:
groups = groups[(page_number - 1) * items_per_page : page_number * items_per_page]
return get_json_result(data={"groups": groups, "total": total})
except Exception as exc:
return server_error_response(exc)
@manager.route("/compilation_template_groups/<group_id>", methods=["GET"]) # noqa: F821
@login_required
def detail(group_id: str) -> Response:
try:
group = CompilationTemplateGroupService.get_saved(group_id, current_user.id)
if group is None:
return get_data_error_result(message=f"Cannot find compilation template group {group_id}.")
return get_json_result(data=group)
except Exception as exc:
return server_error_response(exc)
@manager.route("/compilation_template_groups", methods=["POST"]) # noqa: F821
@login_required
@validate_request("name", "templates")
async def create() -> Response:
req = await get_request_json()
error = _validate_group_payload(req)
if error:
return get_data_error_result(message=error)
name = req["name"].strip()
if CompilationTemplateGroupService.name_exists(current_user.id, name):
return get_data_error_result(message="Duplicated compilation template group name.")
try:
saved = CompilationTemplateGroupService.create_group(
tenant_id=current_user.id,
name=name,
description=req.get("description", ""),
templates=req["templates"],
)
return get_json_result(data=saved)
except GroupValidationError as exc:
return get_data_error_result(message=str(exc))
except Exception as exc:
return server_error_response(exc)
@manager.route("/compilation_template_groups/<group_id>", methods=["PUT"]) # noqa: F821
@login_required
async def update(group_id: str) -> Response:
req = await get_request_json()
error = _validate_group_payload(req, require_all=False)
if error:
return get_data_error_result(message=error)
existing = CompilationTemplateGroupService.get_saved(group_id, current_user.id)
if existing is None:
return get_data_error_result(message=f"Cannot find compilation template group {group_id}.")
name = req.get("name")
if isinstance(name, str):
name = name.strip()
if CompilationTemplateGroupService.name_exists(current_user.id, name, group_id):
return get_data_error_result(message="Duplicated compilation template group name.")
try:
updated = CompilationTemplateGroupService.update_group(
group_id=group_id,
tenant_id=current_user.id,
name=name if isinstance(name, str) else None,
description=req.get("description") if "description" in req else None,
templates=req.get("templates") if "templates" in req else None,
)
if updated is None:
return get_data_error_result(message=f"Cannot find compilation template group {group_id}.")
return get_json_result(data=updated)
except GroupValidationError as exc:
return get_data_error_result(message=str(exc))
except Exception as exc:
return server_error_response(exc)
@manager.route("/compilation_template_groups/<group_id>", methods=["DELETE"]) # noqa: F821
@login_required
def delete(group_id: str) -> Response:
try:
ok = CompilationTemplateGroupService.delete_group(group_id, current_user.id)
if not ok:
return get_data_error_result(message=f"Cannot find compilation template group {group_id}.")
return get_json_result(data=True)
except Exception as exc:
return server_error_response(exc)

View File

@@ -520,7 +520,7 @@ async def search(tenant_id, dataset_id):
req, err = await validate_and_parse_json_request(request, SearchDatasetReq)
if err is not None:
return get_error_argument_result(err)
req['dataset_ids'] = [dataset_id]
req["dataset_ids"] = [dataset_id]
try:
success, result = await dataset_api_service.search_datasets(tenant_id, req)
if success:
@@ -556,6 +556,263 @@ async def get_knowledge_graph(tenant_id, dataset_id):
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/any_artifact", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def has_any_wiki(tenant_id, dataset_id):
"""Probe whether this dataset has any compiled artifact pages.
GET /api/v1/datasets/<dataset_id>/any_artifact
Success: {"code": 0, "data": {"has": bool}}
The frontend uses this to decide whether to surface the Artifact tab
in the dataset sidebar.
"""
try:
success, result = await dataset_api_service.has_any_wiki(dataset_id, tenant_id)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/artifacts", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def list_wiki_pages(tenant_id, dataset_id):
"""List artifact pages for the dataset Artifact tab.
GET /api/v1/datasets/<dataset_id>/artifacts?page=1&page_size=200&page_type=entity
Success: {"code": 0, "data": {"total": int, "items": [{slug, title, page_type}]}}
"""
try:
page = int(request.args.get("page", 1) or 1)
page_size = int(request.args.get("page_size", 200) or 200)
except (TypeError, ValueError):
return get_error_argument_result("page and page_size must be integers")
page_type = request.args.get("page_type") or None
try:
success, result = await dataset_api_service.list_wiki_pages(
dataset_id,
tenant_id,
page=page,
page_size=page_size,
page_type=page_type,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/artifacts/graph", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def get_wiki_graph(tenant_id, dataset_id):
"""Return an incremental slice of the canvas graph for this dataset.
GET /api/v1/datasets/<dataset_id>/artifacts/graph[?node=<slug>]
- ``node`` omitted: overview centred on the heaviest-weighted
entities, expanded outward until ``MAX_LOADING_ENTITY`` is hit.
- ``node`` provided: subgraph centred on that entity, including all
outgoing relations and their ``to`` targets (also capped).
Success: ``{"code": 0, "data": {"entities":[…],"relations":[…]}}``.
"""
try:
node = request.args.get("node", None)
if isinstance(node, str):
node = node.strip() or None
success, result = await dataset_api_service.get_wiki_graph(
dataset_id,
tenant_id,
node=node,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/artifacts", methods=["DELETE"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def clear_wiki(tenant_id, dataset_id):
"""Wipe every artifact-related row from ES for this KB.
DELETE /api/v1/datasets/<dataset_id>/artifacts
Removes the five ``compile_kwd`` row types written by the artifact
pipeline (MAP extracts / REDUCE results / PLAN / page drafts / pages).
Success: {"code": 0, "data": {"deleted": {kwd: result}}}
"""
try:
success, result = await dataset_api_service.clear_wiki(
dataset_id,
tenant_id,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/artifacts/<page_type>/<path:slug>", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def get_wiki_page(tenant_id, dataset_id, page_type, slug):
"""Fetch one artifact page by (page_type, slug).
GET /api/v1/datasets/<dataset_id>/artifacts/<page_type>/<slug>
``slug`` is the tail after the page type — the same form that markdown
links in ``content_md_rendered`` carry as
``artifact/<kb_id>/<page_type>/<slug>``.
Success: {"code": 0, "data": page_dict | null}
"""
try:
success, result = await dataset_api_service.get_wiki_page(
dataset_id,
tenant_id,
page_type,
slug,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/any_skill", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def has_any_skill(tenant_id, dataset_id):
"""Probe whether this dataset has a compiled Corpus2Skill tree.
GET /api/v1/datasets/<dataset_id>/any_skill
Success: {"code": 0, "data": {"has": bool}}
"""
try:
success, result = await dataset_api_service.has_any_skill(dataset_id, tenant_id)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/skills", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def get_skill_tree(tenant_id, dataset_id):
"""Fetch the aggregate recursive Corpus2Skill tree for this dataset.
GET /api/v1/datasets/<dataset_id>/skills
Success: {"code": 0, "data": skill_all_row | null}
"""
try:
success, result = await dataset_api_service.get_skill_tree(
dataset_id,
tenant_id,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/skills/<path:skill_kwd>", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def get_skill_page(tenant_id, dataset_id, skill_kwd):
"""Fetch full markdown for one Corpus2Skill node by skill_kwd.
GET /api/v1/datasets/<dataset_id>/skills/<skill_kwd>
Success: {"code": 0, "data": skill_row | null}
"""
try:
success, result = await dataset_api_service.get_skill_page(
dataset_id,
tenant_id,
skill_kwd,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
# The two artifact-commit endpoints
# GET /datasets/<dataset_id>/artifacts/<page_type>/<path:slug>/commits
# GET /datasets/<dataset_id>/artifacts/commits/<commit_id>
# were retired here — their functionality is now served by the generic
# file-commit routes:
# GET /datasets/<dataset_id>/commits?slug=<page_type>/<name>
# GET /datasets/<dataset_id>/commits/<commit_id>
# See ``api/apps/restful_apis/file_commit_api.py`` and
# ``api/db/services/file_commit_service.py`` (record_page_edit /
# list_page_commits / get_page_commit_detail).
@manager.route("/datasets/<dataset_id>/artifacts/<page_type>/<path:slug>", methods=["PUT"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def update_wiki_page(tenant_id, dataset_id, page_type, slug):
"""Edit one artifact page in place.
PUT /api/v1/datasets/<dataset_id>/artifacts/<page_type>/<slug>
Body: {"content_md": "<markdown>"}
Only the page row is updated — canvas / entity / relation rows stay
stale until the next artifact compile. Success returns the
re-fetched page dict so the dialog can refresh its preview cleanly.
"""
try:
req = await request.get_json()
if not isinstance(req, dict):
return get_error_argument_result("Body must be a JSON object.")
content_md = req.get("content_md")
if not isinstance(content_md, str):
return get_error_argument_result("'content_md' must be a string.")
# Commit metadata — both optional. Title defaults server-side to
# "Edit <slug>" inside record_edit when missing.
title = req.get("title")
comments = req.get("comments")
if title is not None and not isinstance(title, str):
return get_error_argument_result("'title' must be a string.")
if comments is not None and not isinstance(comments, str):
return get_error_argument_result("'comments' must be a string.")
success, result = await dataset_api_service.update_wiki_page(
dataset_id,
tenant_id,
page_type,
slug,
content_md,
user_id=getattr(current_user, "id", None),
title=title,
comments=comments,
)
if success:
return get_result(data=result)
return get_result(data=False, message=result, code=RetCode.AUTHENTICATION_ERROR)
except Exception as e:
logging.exception(e)
return get_error_data_result(message="Internal server error")
@manager.route("/datasets/<dataset_id>/index", methods=["POST"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs

View File

@@ -77,6 +77,58 @@ from common.ssrf_guard import assert_url_is_safe
from rag.nlp import search
def _parser_config_compilation_template_group_ids(parser_config) -> list[str]:
"""Read template-group ids from a doc's parser_config.
The doc now references compilation template groups via a list. A
legacy single string id is still accepted. Old
``compilation_template_ids`` data is
intentionally ignored per the migration spec.
"""
def _normalize(raw) -> list[str]:
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
return []
ids: list[str] = []
seen: set[str] = set()
for gid in raw:
if not isinstance(gid, str):
continue
gid = gid.strip()
if gid and gid not in seen:
seen.add(gid)
ids.append(gid)
return ids
if not isinstance(parser_config, dict):
return []
if "compilation_template_group_id" in parser_config:
return _normalize(parser_config.get("compilation_template_group_id"))
ext = parser_config.get("ext")
if isinstance(ext, dict):
return _normalize(ext.get("compilation_template_group_id"))
return []
def _compilation_template_group_id_changed(old_config, new_config) -> bool:
return _parser_config_compilation_template_group_ids(old_config) != _parser_config_compilation_template_group_ids(new_config)
def _normalize_parser_config_compilation_template_group_ids(parser_config) -> bool:
if not isinstance(parser_config, dict):
return False
if "compilation_template_group_id" not in parser_config and not (isinstance(parser_config.get("ext"), dict) and "compilation_template_group_id" in parser_config["ext"]):
return False
group_ids = _parser_config_compilation_template_group_ids(parser_config)
parser_config["compilation_template_group_id"] = group_ids
ext = parser_config.get("ext")
if isinstance(ext, dict) and "compilation_template_group_id" in ext:
ext["compilation_template_group_id"] = group_ids
return True
@manager.route("/documents/upload", methods=["POST"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
@@ -233,9 +285,16 @@ async def update_document(tenant_id, dataset_id, document_id):
if "parser_id" in req and ((doc.type == FileType.VISUAL and req["parser_id"] != "picture") or (re.search(r"\.(ppt|pptx|pages)$", doc.name) and req["parser_id"] != "presentation")):
return get_data_error_result(message="Not supported yet!")
# parser config provided (already validated in UpdateDocumentReq), update it
parser_config_template_group_changed = False
# parser config provided (already validated in UpdateDocumentReq), update it.
# Changing the document-scoped knowledge compilation template group
# affects parse output, so the document must be parsed again for it to
# execute.
if update_doc_req.parser_config:
old_parser_config = dict(doc.parser_config or {})
req["parser_config"].update(update_doc_req.parser_config.ext)
parser_config_template_group_touched = _normalize_parser_config_compilation_template_group_ids(req["parser_config"])
parser_config_template_group_changed = parser_config_template_group_touched and _compilation_template_group_id_changed(old_parser_config, req["parser_config"])
DocumentService.update_parser_config(doc.id, req["parser_config"])
# pipeline_id provided - reset document for reparse
@@ -246,6 +305,12 @@ async def update_document(tenant_id, dataset_id, document_id):
elif update_doc_req.chunk_method:
if error := update_chunk_method(req, doc, tenant_id):
return error
if parser_config_template_group_changed and doc.parser_id.lower() == req["chunk_method"].lower():
if error := reset_document_for_reparse(doc, tenant_id):
return error
elif parser_config_template_group_changed:
if error := reset_document_for_reparse(doc, tenant_id):
return error
if "enabled" in req: # already checked in UpdateDocumentReq - it's int if present
# "enabled" flag provided, the update method will check if it's changed and then update if so

View File

@@ -16,19 +16,11 @@
import logging
from functools import wraps
from quart import request
from api.apps import login_required, current_user
from api.utils.api_utils import get_json_result, get_data_error_result, get_request_json, server_error_response, validate_request
# manager is injected dynamically by api.apps.register_page() before this
# module is exec'd. DO NOT assign manager = None here — it would overwrite
# the Blueprint that register_page set on the module.
from api.db.services.file_commit_service import FileCommitService
from api.db.services.knowledgebase_service import KnowledgebaseService
from api.db.services.file_service import FileService
from common.constants import FileSource
logger = logging.getLogger(__name__)
@@ -45,12 +37,16 @@ def _register_resolver(entity_type):
The decorated function receives (entity_id) and must return a folder_id
or None if the entity has no corresponding folder.
"""
def decorator(func):
_ENTITY_RESOLVERS[entity_type] = func
@wraps(func)
def wrapper(entity_id):
return func(entity_id)
return wrapper
return decorator
@@ -64,23 +60,29 @@ def _resolve_folder_id(entity_type, entity_id):
@_register_resolver("datasets")
def _resolve_dataset_folder(dataset_id):
success, kb = KnowledgebaseService.get_by_id(dataset_id)
"""For the ``/datasets/<dataset_id>/commits`` scope we now serve
artifact-page history rather than workspace file commits.
Artifact commits are written via
:meth:`FileCommitService.record_page_edit` with ``folder_id = kb_id``,
so this resolver simply returns the dataset id verbatim. Workspace
file-commit browsing for the same KB still works via ``/workspace/*``
or ``/folders/*`` with the real folder id — the two commit domains
coexist in ``file_commit`` but never mix under the same folder_id.
A quick existence check keeps us honest: returning ``None`` for a
missing KB drives ``_resolve`` to reject the request before it hits
a query.
"""
success, _kb = KnowledgebaseService.get_by_id(dataset_id)
if not success:
return None
# Find the folder with matching name, source_type, and tenant_id
folders = FileService.query(
name=kb.name,
source_type=FileSource.KNOWLEDGEBASE.value,
type="folder",
tenant_id=kb.tenant_id,
)
if folders:
return folders[0].id
return None
return dataset_id
# ── Route registration helper ─────────────────────────────────────────────
def _register_commit_routes(prefix, param_name, resolver_type=None):
"""Register all 8 commit endpoints for a given URL prefix.
@@ -102,7 +104,7 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
return folder_id
# ── Create commit ──────────────────────────────────────────────────────
@manager.route(f'{prefix}/commits', methods=['POST'], endpoint=f'create_commit_{_n}') # noqa: F821
@manager.route(f"{prefix}/commits", methods=["POST"], endpoint=f"create_commit_{_n}") # noqa: F821
@login_required
@validate_request("message", "files")
async def create_commit(entity_id):
@@ -115,21 +117,27 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
message=req["message"],
file_changes=req["files"],
)
return get_json_result(data={
"id": commit.id,
"folder_id": commit.folder_id,
"parent_id": commit.parent_id,
"message": commit.message,
"author_id": commit.author_id,
"file_count": commit.file_count,
"tree_state": commit.tree_state,
"create_time": commit.create_time,
})
return get_json_result(
data={
"id": commit.id,
"folder_id": commit.folder_id,
"parent_id": commit.parent_id,
"message": commit.message,
"author_id": commit.author_id,
"file_count": commit.file_count,
"tree_state": commit.tree_state,
"create_time": commit.create_time,
}
)
except Exception as e:
return server_error_response(e)
# ── List commits ───────────────────────────────────────────────────────
@manager.route(f'{prefix}/commits', methods=['GET'], endpoint=f'list_commits_{_n}') # noqa: F821
# Accepts an optional ``?slug=<page_type>/<name>`` filter to serve
# per-artifact-page history. When ``slug`` is set we delegate to
# ``list_page_commits`` (indexed join on FileCommitItem.slug_kwd);
# otherwise this is the plain folder-wide commit list.
@manager.route(f"{prefix}/commits", methods=["GET"], endpoint=f"list_commits_{_n}") # noqa: F821
@login_required
async def list_commits(entity_id):
folder_id = _resolve(entity_id)
@@ -138,26 +146,57 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
page_size = int(request.args.get("page_size", 15))
order_by = request.args.get("order_by", "create_time")
desc = request.args.get("desc", "true").lower() != "false"
slug = request.args.get("slug") or ""
if slug:
total, items = FileCommitService.list_page_commits(
tenant_id="", # scoped implicitly via folder_id == kb_id
kb_id=folder_id,
slug=slug,
page=page,
page_size=page_size,
)
return get_json_result(
data={
"total": total,
"page": page,
"page_size": page_size,
"commits": items,
}
)
commits, total = FileCommitService.list_commits(folder_id, page, page_size, order_by, desc)
return get_json_result(data={
"total": total,
"page": page,
"page_size": page_size,
"commits": [{
"id": c.id,
"folder_id": c.folder_id,
"parent_id": c.parent_id,
"message": c.message,
"author_id": c.author_id,
"file_count": c.file_count,
"create_time": c.create_time,
} for c in commits],
})
return get_json_result(
data={
"total": total,
"page": page,
"page_size": page_size,
"commits": [
{
"id": c.id,
"folder_id": c.folder_id,
"parent_id": c.parent_id,
"message": c.message,
"author_id": c.author_id,
"file_count": c.file_count,
"create_time": c.create_time,
# Artifact-commit extension — null for workspace commits.
"title": getattr(c, "title", None),
"comments": getattr(c, "comments", None),
}
for c in commits
],
}
)
except Exception as e:
return server_error_response(e)
# ── Get commit ─────────────────────────────────────────────────────────
@manager.route(f'{prefix}/commits/<commit_id>', methods=['GET'], endpoint=f'get_commit_{_n}') # noqa: F821
# For artifact commits (folder_id == kb_id) we route through
# ``get_page_commit_detail`` which resolves ``content_after`` from
# the configured blob store and returns the flat shape the old
# ``/artifacts/commits/<id>`` endpoint used to serve.
@manager.route(f"{prefix}/commits/<commit_id>", methods=["GET"], endpoint=f"get_commit_{_n}") # noqa: F821
@login_required
async def get_commit(entity_id, commit_id):
folder_id = _resolve(entity_id)
@@ -167,29 +206,47 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
return get_data_error_result("Commit not found")
if commit.folder_id != folder_id:
return get_data_error_result("Commit not found in workspace")
# Artifact commits carry a non-null ``title``; use that as
# the discriminator to pick the enriched response shape.
if getattr(commit, "title", None):
detail = FileCommitService.get_page_commit_detail(
tenant_id="",
kb_id=folder_id,
commit_id=commit_id,
)
if detail is None:
return get_data_error_result("Commit not found")
return get_json_result(data=detail)
items = FileCommitService.list_commit_files(commit_id)
return get_json_result(data={
"id": commit.id,
"folder_id": commit.folder_id,
"parent_id": commit.parent_id,
"message": commit.message,
"author_id": commit.author_id,
"file_count": commit.file_count,
"create_time": commit.create_time,
"files": [{
"file_id": item.file_id,
"operation": item.operation,
"old_hash": item.old_hash,
"new_hash": item.new_hash,
"old_name": item.old_name,
"new_name": item.new_name,
} for item in items],
})
return get_json_result(
data={
"id": commit.id,
"folder_id": commit.folder_id,
"parent_id": commit.parent_id,
"message": commit.message,
"author_id": commit.author_id,
"file_count": commit.file_count,
"create_time": commit.create_time,
"files": [
{
"file_id": item.file_id,
"operation": item.operation,
"old_hash": item.old_hash,
"new_hash": item.new_hash,
"old_name": item.old_name,
"new_name": item.new_name,
}
for item in items
],
}
)
except Exception as e:
return server_error_response(e)
# ── List commit files ──────────────────────────────────────────────────
@manager.route(f'{prefix}/commits/<commit_id>/files', methods=['GET'], endpoint=f'list_commit_files_{_n}') # noqa: F821
@manager.route(f"{prefix}/commits/<commit_id>/files", methods=["GET"], endpoint=f"list_commit_files_{_n}") # noqa: F821
@login_required
async def list_commit_files(entity_id, commit_id):
folder_id = _resolve(entity_id)
@@ -200,22 +257,27 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
if commit.folder_id != folder_id:
return get_data_error_result("Commit not found in workspace")
items = FileCommitService.list_commit_files(commit_id)
return get_json_result(data=[{
"id": item.id,
"file_id": item.file_id,
"operation": item.operation,
"old_hash": item.old_hash,
"new_hash": item.new_hash,
"old_location": item.old_location,
"new_location": item.new_location,
"old_name": item.old_name,
"new_name": item.new_name,
} for item in items])
return get_json_result(
data=[
{
"id": item.id,
"file_id": item.file_id,
"operation": item.operation,
"old_hash": item.old_hash,
"new_hash": item.new_hash,
"old_location": item.old_location,
"new_location": item.new_location,
"old_name": item.old_name,
"new_name": item.new_name,
}
for item in items
]
)
except Exception as e:
return server_error_response(e)
# ── Diff commits ───────────────────────────────────────────────────────
@manager.route(f'{prefix}/commits/diff', methods=['GET'], endpoint=f'diff_commits_{_n}') # noqa: F821
@manager.route(f"{prefix}/commits/diff", methods=["GET"], endpoint=f"diff_commits_{_n}") # noqa: F821
@login_required
async def diff_commits(entity_id):
folder_id = _resolve(entity_id)
@@ -236,7 +298,7 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
return server_error_response(e)
# ── Get uncommitted changes ────────────────────────────────────────────
@manager.route(f'{prefix}/changes', methods=['GET'], endpoint=f'get_uncommitted_changes_{_n}') # noqa: F821
@manager.route(f"{prefix}/changes", methods=["GET"], endpoint=f"get_uncommitted_changes_{_n}") # noqa: F821
@login_required
async def get_uncommitted_changes(entity_id):
folder_id = _resolve(entity_id)
@@ -247,7 +309,7 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
return server_error_response(e)
# ── Get commit tree ────────────────────────────────────────────────────
@manager.route(f'{prefix}/commits/<commit_id>/tree', methods=['GET'], endpoint=f'get_commit_tree_{_n}') # noqa: F821
@manager.route(f"{prefix}/commits/<commit_id>/tree", methods=["GET"], endpoint=f"get_commit_tree_{_n}") # noqa: F821
@login_required
async def get_commit_tree(entity_id, commit_id):
folder_id = _resolve(entity_id)
@@ -263,7 +325,7 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
return server_error_response(e)
# ── Get commit file content ────────────────────────────────────────────
@manager.route(f'{prefix}/commits/<commit_id>/files/<file_id>/content', methods=['GET'], endpoint=f'get_commit_file_content_{_n}') # noqa: F821
@manager.route(f"{prefix}/commits/<commit_id>/files/<file_id>/content", methods=["GET"], endpoint=f"get_commit_file_content_{_n}") # noqa: F821
@login_required
async def get_commit_file_content(entity_id, commit_id, file_id):
folder_id = _resolve(entity_id)
@@ -282,14 +344,15 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
# Expose handlers at module level for direct testing.
_g = globals()
_g['create_commit'] = create_commit
_g['list_commits'] = list_commits
_g['get_commit'] = get_commit
_g['list_commit_files'] = list_commit_files
_g['diff_commits'] = diff_commits
_g['get_uncommitted_changes'] = get_uncommitted_changes
_g['get_commit_tree'] = get_commit_tree
_g['get_commit_file_content'] = get_commit_file_content
_g["create_commit"] = create_commit
_g["list_commits"] = list_commits
_g["get_commit"] = get_commit
_g["list_commit_files"] = list_commit_files
_g["diff_commits"] = diff_commits
_g["get_uncommitted_changes"] = get_uncommitted_changes
_g["get_commit_tree"] = get_commit_tree
_g["get_commit_file_content"] = get_commit_file_content
# ── Register routes for all entity types ──────────────────────────────────
# All URL patterns use <entity_id> as the consistent param name.
@@ -297,14 +360,14 @@ def _register_commit_routes(prefix, param_name, resolver_type=None):
# For other entity types entity_id is resolved via _resolve_folder_id().
# Register datasets first, workspace second, folders last —
# the last call's handlers overwrite module-level names for test access.
_register_commit_routes('/datasets/<entity_id>', 'entity_id', resolver_type='datasets')
_register_commit_routes('/workspace/<entity_id>', 'entity_id') # alias — workspace_id == folder_id
_register_commit_routes('/folders/<entity_id>', 'entity_id') # direct — entity_id == folder_id (wins)
_register_commit_routes("/datasets/<entity_id>", "entity_id", resolver_type="datasets")
_register_commit_routes("/workspace/<entity_id>", "entity_id") # alias — workspace_id == folder_id
_register_commit_routes("/folders/<entity_id>", "entity_id") # direct — entity_id == folder_id (wins)
# /memories and /skills routes are not mounted until resolvers are implemented.
# ── File version history (shared across all entity types) ─────────────────
@manager.route('/files/<file_id>/versions', methods=['GET']) # noqa: F821
@manager.route("/files/<file_id>/versions", methods=["GET"]) # noqa: F821
@login_required
async def get_file_version_history(file_id):
try:

View File

@@ -0,0 +1,79 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
def validate_template_payload(req: dict, require_all: bool = True) -> str:
"""Validate a single template payload (kind + config + name)."""
required = ["name", "kind", "config"] if require_all else []
for key in required:
if key not in req:
return f"Missing required field: {key}."
name = req.get("name")
if name is not None and (not isinstance(name, str) or not name.strip() or len(name.encode("utf-8")) > 128):
return "Invalid template name."
description = req.get("description")
if description is not None and (not isinstance(description, str) or len(description) > 1024):
return "Invalid template description."
kind = req.get("kind")
if kind is not None and (not isinstance(kind, str) or not kind):
return "Invalid template kind."
config = req.get("config")
if config is not None and not isinstance(config, dict):
return "Invalid template config."
if isinstance(config, dict):
if len(str(config.get("global_rules") or "")) > 4096:
return "Global compilation rules is too long."
for section in ["entity", "relation"]:
fields = (config.get(section) or {}).get("fields") or []
seen_types = set()
for field in fields:
field_type = str((field or {}).get("type") or "").strip()
if not field_type:
return f"{section.capitalize()} type is required."
if field_type in seen_types:
return f"{section.capitalize()} type can not be duplicated."
seen_types.add(field_type)
if not str((field or {}).get("description") or "").strip():
return f"{section.capitalize()} field description is required."
if len(str((field or {}).get("description") or "")) > 1024:
return f"{section.capitalize()} field description is too long."
if len(str((field or {}).get("rule") or "")) > 1024:
return f"{section.capitalize()} field rule is too long."
if config.get("kind") == "artifacts" or req.get("kind") == "artifacts":
for field in (config.get("claim") or {}).get("fields") or []:
if not str((field or {}).get("statement") or "").strip():
return "Claim statement is required."
if not str((field or {}).get("subject") or "").strip():
return "Claim subject is required."
if len(str((field or {}).get("statement") or "")) > 1024:
return "Claim statement is too long."
if len(str((field or {}).get("subject") or "")) > 1024:
return "Claim subject is too long."
for field in (config.get("concept") or {}).get("fields") or []:
if not str((field or {}).get("term") or "").strip():
return "Concept term is required."
if not str((field or {}).get("definition_excerpt") or "").strip():
return "Concept definition excerpt is required."
if len(str((field or {}).get("term") or "")) > 1024:
return "Concept term is too long."
if len(str((field or {}).get("definition_excerpt") or "")) > 1024:
return "Concept definition excerpt is too long."
return ""

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,7 @@ def update_document_name_only(document_id, req_doc_name):
)
return None
def update_chunk_method(req, doc, tenant_id):
"""
Update chunk method only (without validation).
@@ -146,7 +147,7 @@ def reset_document_for_reparse(doc, tenant_id, parser_id=None, pipeline_id=None)
return None
def update_document_status_only(status:int, doc, kb):
def update_document_status_only(status: int, doc, kb):
"""
Update document status only (without validation).
@@ -165,13 +166,18 @@ def update_document_status_only(status:int, doc, kb):
try:
if not DocumentService.update_by_id(doc.id, {"status": str(status)}):
return get_error_data_result(message="Database error (Document update)!")
settings.docStoreConn.update({"doc_id": doc.id}, {"available_int": status}, search.index_name(kb.tenant_id), doc.kb_id)
settings.docStoreConn.update(
{"doc_id": doc.id, "must_not": {"exists": "compile_kwd"}},
{"available_int": status},
search.index_name(kb.tenant_id),
doc.kb_id,
)
except Exception as e:
return server_error_response(e)
return None
def validate_document_update_fields(update_doc_req:UpdateDocumentReq, doc, req):
def validate_document_update_fields(update_doc_req: UpdateDocumentReq, doc, req):
"""
Validate document update fields in a single method.
@@ -269,7 +275,7 @@ def _process_key_mappings(doc):
}
# Handle both dict and model input
items = doc.to_dict().items() if hasattr(doc, 'to_dict') else doc.items()
items = doc.to_dict().items() if hasattr(doc, "to_dict") else doc.items()
renamed_doc = {}
for key, value in items: