mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-08 20:34:48 +08:00
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:
@@ -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:
|
||||
|
||||
54
api/apps/restful_apis/compilation_template_api.py
Normal file
54
api/apps/restful_apis/compilation_template_api.py
Normal 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)
|
||||
172
api/apps/restful_apis/compilation_template_group_api.py
Normal file
172
api/apps/restful_apis/compilation_template_group_api.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user