Feat: add wiki folder (#16749)

### Summary

Add wiki folders.
This commit is contained in:
Kevin Hu
2026-07-08 20:08:14 +08:00
committed by GitHub
parent 8e3bbad4da
commit 2c59d07bdb
14 changed files with 893 additions and 264 deletions

View File

@@ -583,7 +583,7 @@ async def has_any_wiki(tenant_id, dataset_id):
async def list_wiki_pages(tenant_id, dataset_id): async def list_wiki_pages(tenant_id, dataset_id):
"""List artifact pages for the dataset Artifact tab. """List artifact pages for the dataset Artifact tab.
GET /api/v1/datasets/<dataset_id>/artifacts?page=1&page_size=200&page_type=entity GET /api/v1/datasets/<dataset_id>/artifacts?page=1&page_size=200&page_type=entity&topic=topic
Success: {"code": 0, "data": {"total": int, "items": [{slug, title, page_type}]}} Success: {"code": 0, "data": {"total": int, "items": [{slug, title, page_type}]}}
""" """
try: try:
@@ -591,7 +591,8 @@ async def list_wiki_pages(tenant_id, dataset_id):
page_size = int(request.args.get("page_size", 200) or 200) page_size = int(request.args.get("page_size", 200) or 200)
except (TypeError, ValueError): except (TypeError, ValueError):
return get_error_argument_result("page and page_size must be integers") return get_error_argument_result("page and page_size must be integers")
page_type = request.args.get("page_type") or None page_type = (request.args.get("page_type") or "").strip() or None
topic = (request.args.get("topic") or "").strip() or None
try: try:
success, result = await dataset_api_service.list_wiki_pages( success, result = await dataset_api_service.list_wiki_pages(
@@ -600,6 +601,37 @@ async def list_wiki_pages(tenant_id, dataset_id):
page=page, page=page,
page_size=page_size, page_size=page_size,
page_type=page_type, page_type=page_type,
topic=topic,
)
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_topics", methods=["GET"]) # noqa: F821
@login_required
@add_tenant_id_to_kwargs
async def list_wiki_topics(tenant_id, dataset_id):
"""List wiki topics for the dataset Artifact tab.
GET /api/v1/datasets/<dataset_id>/artifacts_topics?page=1&page_size=200
Success: {"code": 0, "data": {"total": int, "items": [{topic, title, slug}]}}
"""
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")
try:
success, result = await dataset_api_service.list_wiki_topics(
dataset_id,
tenant_id,
page=page,
page_size=page_size,
) )
if success: if success:
return get_result(data=result) return get_result(data=result)
@@ -647,8 +679,8 @@ async def clear_wiki(tenant_id, dataset_id):
"""Wipe every artifact-related row from ES for this KB. """Wipe every artifact-related row from ES for this KB.
DELETE /api/v1/datasets/<dataset_id>/artifacts DELETE /api/v1/datasets/<dataset_id>/artifacts
Removes the five ``compile_kwd`` row types written by the artifact Removes the artifact ``compile_kwd`` row types written by the artifact
pipeline (MAP extracts / REDUCE results / PLAN / page drafts / pages). pipeline (MAP extracts / REDUCE results / PLAN / drafts / pages / topics / graph rows).
Success: {"code": 0, "data": {"deleted": {kwd: result}}} Success: {"code": 0, "data": {"deleted": {kwd: result}}}
""" """
try: try:

View File

@@ -1496,11 +1496,12 @@ async def search_datasets(tenant_id: str, req: dict):
# with ``compile_kwd="artifact_page"`` written by TaskHandler's # with ``compile_kwd="artifact_page"`` written by TaskHandler's
# ``_persist_wiki_pages_to_es``. The schema fields they rely on are: # ``_persist_wiki_pages_to_es``. The schema fields they rely on are:
# slug_kwd, title_kwd, page_type_kwd, content_with_weight, # slug_kwd, title_kwd, page_type_kwd, content_with_weight,
# entity_names_kwd, outlinks_kwd, related_kb_pages_kwd, # topic_kwd, entity_names_kwd, outlinks_kwd, related_kb_pages_kwd,
# source_chunk_ids, source_doc_ids # source_chunk_ids, source_doc_ids
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_WIKI_COMPILE_KWD = "artifact_page" _WIKI_COMPILE_KWD = "artifact_page"
_WIKI_TOPIC_COMPILE_KWD = "artifact_page_topic"
_SKILL_COMPILE_KWD = "skill" _SKILL_COMPILE_KWD = "skill"
_SKILL_ALL_COMPILE_KWD = "skill_all" _SKILL_ALL_COMPILE_KWD = "skill_all"
@@ -1568,6 +1569,7 @@ async def list_wiki_pages(
page: int = 1, page: int = 1,
page_size: int = 200, page_size: int = 200,
page_type: str | None = None, page_type: str | None = None,
topic: str | None = None,
): ):
"""List artifact pages for the left-hand 2-column list. """List artifact pages for the left-hand 2-column list.
@@ -1589,10 +1591,14 @@ async def list_wiki_pages(
page = max(1, int(page or 1)) page = max(1, int(page or 1))
page_size = max(1, min(int(page_size or 200), 1000)) page_size = max(1, min(int(page_size or 200), 1000))
offset = (page - 1) * page_size offset = (page - 1) * page_size
page_type = page_type.strip() if isinstance(page_type, str) else page_type
topic = topic.strip() if isinstance(topic, str) else topic
condition: dict = {"compile_kwd": [_WIKI_COMPILE_KWD]} condition: dict = {"compile_kwd": [_WIKI_COMPILE_KWD]}
if page_type: if page_type:
condition["page_type_kwd"] = [page_type] condition["page_type_kwd"] = [page_type]
if topic:
condition["topic_kwd"] = [topic]
order_by = OrderByExpr() order_by = OrderByExpr()
try: try:
@@ -1609,6 +1615,7 @@ async def list_wiki_pages(
"slug_kwd", "slug_kwd",
"title_kwd", "title_kwd",
"page_type_kwd", "page_type_kwd",
"topic_kwd",
"outlinks_int", "outlinks_int",
"summary_with_weight", "summary_with_weight",
] ]
@@ -1640,6 +1647,7 @@ async def list_wiki_pages(
"slug": slug, "slug": slug,
"title": row.get("title_kwd") or slug, "title": row.get("title_kwd") or slug,
"page_type": row.get("page_type_kwd") or "concept", "page_type": row.get("page_type_kwd") or "concept",
"topic": row.get("topic_kwd") or "",
"summary": row.get("summary_with_weight") or "", "summary": row.get("summary_with_weight") or "",
} }
) )
@@ -1647,6 +1655,74 @@ async def list_wiki_pages(
return True, {"total": int(total or 0), "items": items} return True, {"total": int(total or 0), "items": items}
async def list_wiki_topics(
dataset_id: str,
tenant_id: str,
page: int = 1,
page_size: int = 200,
):
"""List wiki topics for the dataset Artifact tab."""
if not KnowledgebaseService.accessible(dataset_id, tenant_id):
return False, "No authorization."
_, kb = KnowledgebaseService.get_by_id(dataset_id)
pack = _wiki_index_or_none(kb.tenant_id, dataset_id)
if pack is None:
return True, {"total": 0, "items": []}
index_nm, _ = pack
from common.doc_store.doc_store_base import OrderByExpr
page = max(1, int(page or 1))
page_size = max(1, min(int(page_size or 200), 1000))
offset = (page - 1) * page_size
order_by = OrderByExpr()
try:
order_by.asc("title_kwd")
except Exception:
order_by = OrderByExpr()
select_fields = [
"id",
"topic_kwd",
"title_kwd",
"slug_kwd",
]
try:
res = settings.docStoreConn.search(
select_fields=select_fields,
highlight_fields=[],
condition={"compile_kwd": [_WIKI_TOPIC_COMPILE_KWD]},
match_expressions=[],
order_by=order_by,
offset=offset,
limit=page_size,
index_names=index_nm,
knowledgebase_ids=[dataset_id],
)
field_map = settings.docStoreConn.get_fields(res, select_fields)
except Exception:
logging.exception("list_wiki_topics: docStore search failed for kb=%s", dataset_id)
return True, {"total": 0, "items": []}
total = settings.docStoreConn.get_total(res)
items = []
for row in (field_map or {}).values():
topic = row.get("topic_kwd")
if not isinstance(topic, str) or not topic:
continue
items.append(
{
"topic": topic,
"title": row.get("title_kwd") or topic,
"slug": row.get("slug_kwd") or topic,
}
)
return True, {"total": int(total or 0), "items": items}
async def get_wiki_page( async def get_wiki_page(
dataset_id: str, dataset_id: str,
tenant_id: str, tenant_id: str,
@@ -1679,6 +1755,7 @@ async def get_wiki_page(
"slug_kwd", "slug_kwd",
"title_kwd", "title_kwd",
"page_type_kwd", "page_type_kwd",
"topic_kwd",
"content_with_weight", "content_with_weight",
"summary_with_weight", "summary_with_weight",
"entity_names_kwd", "entity_names_kwd",
@@ -1722,6 +1799,7 @@ async def get_wiki_page(
"slug": row.get("slug_kwd") or full_slug, "slug": row.get("slug_kwd") or full_slug,
"title": row.get("title_kwd") or full_slug, "title": row.get("title_kwd") or full_slug,
"page_type": row.get("page_type_kwd") or page_type, "page_type": row.get("page_type_kwd") or page_type,
"topic": row.get("topic_kwd") or "",
"content_md_rendered": content_md, "content_md_rendered": content_md,
"summary": summary, "summary": summary,
"entity_names": row.get("entity_names_kwd") or [], "entity_names": row.get("entity_names_kwd") or [],
@@ -2031,7 +2109,7 @@ async def update_wiki_page(
# :meth:`FileCommitService.get_page_commit_detail`. # :meth:`FileCommitService.get_page_commit_detail`.
# All six row types the artifact pipeline writes. Listed in dependency # All seven row types the artifact pipeline writes. Listed in dependency
# order so partial failures of earlier deletes don't leave behind state # order so partial failures of earlier deletes don't leave behind state
# that downstream phases would silently reuse. ``artifact_page_graph`` # that downstream phases would silently reuse. ``artifact_page_graph``
# is the materialized canvas graph derived from the refined pages — # is the materialized canvas graph derived from the refined pages —
@@ -2042,6 +2120,7 @@ _WIKI_COMPILE_KWDS = (
"artifact_compilation_plan", "artifact_compilation_plan",
"artifact_page_draft", "artifact_page_draft",
"artifact_page", "artifact_page",
_WIKI_TOPIC_COMPILE_KWD,
"artifact_entity", "artifact_entity",
"artifact_relation", "artifact_relation",
) )
@@ -2470,11 +2549,11 @@ async def get_wiki_graph(
async def clear_wiki(dataset_id: str, tenant_id: str): async def clear_wiki(dataset_id: str, tenant_id: str):
"""Wipe every artifact-related row from ES for this KB. """Wipe every artifact-related row from ES for this KB.
Touches all five ``compile_kwd`` row types the artifact pipeline writes Touches all artifact ``compile_kwd`` row types the artifact pipeline writes
(MAP extracts, REDUCE results, PLAN output, page drafts, and the (MAP extracts, REDUCE results, PLAN output, drafts, pages, topics, and graph
searchable artifact_page rows). After this completes the next "Artifact" rows). After this completes the next "Artifact" run starts from a clean
run starts from a clean slate no resume cache to short-circuit MAP, no slate: no resume cache to short-circuit MAP, no prior pages to reconcile
prior pages to reconcile against in PLAN. against in PLAN.
Returns ``(True, {"deleted": {kwd: count_or_True}})`` on success or Returns ``(True, {"deleted": {kwd: count_or_True}})`` on success or
``(False, str)`` on auth failure. ``(False, str)`` on auth failure.

View File

@@ -58,6 +58,7 @@
"doc_ids_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "doc_ids_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"slug_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "slug_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"title_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "title_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"topic_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"page_type_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "page_type_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"entity_names_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "entity_names_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},
"outlinks_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"}, "outlinks_kwd": {"type": "varchar", "default": "", "analyzer": "whitespace-#"},

View File

@@ -0,0 +1,330 @@
#
# Copyright 2025 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.
#
"""Handler-free core for document-scoped knowledge (structure) compilation.
Extracted from
``rag/svr/task_executor_refactor/chunk_post_processor.py`` so the same
template resolution, batching, accumulate / merge-flush and synthesis logic
can be driven from two places:
* the chunking **task executor**, which streams a document's chunks out of
the doc store (``run_document_structure_compile``), and
* the ``rag.flow`` **Compiler** component, which receives chunks in-memory
from an upstream pipeline node.
Only the non-``tree`` template kinds are handled here. ``tree`` templates run
RAPTOR over the whole document and are still driven from the task executor
(``run_tree_templates``), which owns the doc-store reload + ``RaptorService``.
"""
from __future__ import annotations
import asyncio
import logging
from typing import AsyncIterator, Callable
from api.db.services.compilation_template_service import CompilationTemplateService
from api.db.services.compilation_template_group_service import (
CompilationTemplateGroupService,
)
from api.db.services.llm_service import LLMBundle
from common.exceptions import TaskCanceledException
from rag.advanced_rag.knowlege_compile.structure import (
CHAIN_KINDS,
compile_structure_from_text,
merge_compiled_structures,
validate_and_correct_chain,
)
# ----- tunables ------------------------------------------------------
# Bound how many source chunks are handed to a single
# ``compile_structure_from_text`` invocation. The call fans them out
# across max_workers internally, so a moderate window keeps memory +
# LLM-context pressure predictable for long docs.
DOC_STRUCTURE_COMPILE_BATCH_CHUNKS = 4
# Bound how many compiled ES-ready docs may accumulate before we flush
# them through ``merge_compiled_structures``. The merger does pairwise
# cosine + LLM duplicate-judging, so it's the more expensive step; we
# cap the per-flush set to keep the local-dedup buckets tractable.
DOC_STRUCTURE_MERGE_MAX_DOCS = 512
# Hard wall on the chain-validator LLM correction step. ``list`` and
# ``timeline`` kinds run this just before each merge flush; anything
# longer than this is treated as a blocked LLM and the uncorrected
# docs are flushed instead.
STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S = 120.0
# ----- template resolution -------------------------------------------
def resolve_template_ids_from_groups(group_ids, tenant_id: str) -> list[str]:
"""Resolve an ordered, de-duplicated list of compilation-template ids
from a list of template-*group* ids.
Mirrors ``_parser_config_compilation_template_ids`` but takes the group
ids directly (the ``rag.flow`` Compiler carries them as a component
parameter rather than inside ``parser_config``).
"""
template_ids: list[str] = []
seen: set[str] = set()
for group_id in group_ids or []:
if not isinstance(group_id, str) or not group_id.strip():
continue
for template_id in CompilationTemplateGroupService.resolve_template_ids(
group_id.strip(),
tenant_id,
):
if template_id in seen:
continue
seen.add(template_id)
template_ids.append(template_id)
return template_ids
def load_active_templates(template_ids, tenant_id: str) -> list[tuple[str, dict]]:
"""Load each template's saved config and keep only the ones that drive a
real, non-``artifacts`` structure compilation.
Returns ``[(template_id, parser_cfg), ...]`` — templates that are missing,
have an invalid config, or resolve to no/``artifacts`` kind are dropped
(with a warning for the missing/invalid cases).
"""
from api.apps.restful_apis.chunk_api import _compilation_template_kind
active_templates: list[tuple[str, dict]] = []
for template_id in template_ids:
template = CompilationTemplateService.get_saved(template_id, tenant_id)
if not template:
logging.warning("document_structure_compile: template %s not found", template_id)
continue
parser_cfg = template.get("config") or {}
if not isinstance(parser_cfg, dict):
logging.warning("document_structure_compile: template %s config is invalid", template_id)
continue
kind = _compilation_template_kind(parser_cfg.get("kind"))
if not kind or kind == "artifacts":
continue
active_templates.append((template_id, parser_cfg))
return active_templates
def split_tree_templates(
active_templates: list[tuple[str, dict]],
) -> tuple[list[tuple[str, dict]], list[tuple[str, dict]]]:
"""Partition templates into ``(tree, non_tree)`` by kind."""
from api.apps.restful_apis.chunk_api import _compilation_template_kind
tree_templates: list[tuple[str, dict]] = []
non_tree_templates: list[tuple[str, dict]] = []
for tid, cfg in active_templates:
if _compilation_template_kind((cfg or {}).get("kind")) == "tree":
tree_templates.append((tid, cfg))
else:
non_tree_templates.append((tid, cfg))
return tree_templates, non_tree_templates
# ----- non-tree compilation core -------------------------------------
async def run_structure_compile_over_batches(
*,
active_templates: list[tuple[str, dict]],
chat_mdl_by_tid: dict[str, LLMBundle],
embedding_model: LLMBundle,
tenant_id: str,
kb_id: str,
doc_id: str,
language: str,
chunk_batches: AsyncIterator[list[dict]],
progress_cb: Callable[..., None],
cancel_check: Callable[[], bool] = lambda: False,
record: Callable[[str, dict], None] | None = None,
) -> dict[str, dict]:
"""Extract + merge structures for every non-``tree`` template over an
async stream of chunk batches, then run the optional synthesis phase.
``active_templates`` must already be the non-tree subset with a resolved
chat model in ``chat_mdl_by_tid``. Chunks arrive as an async iterator of
batches so callers can stream them from the doc store or hand over an
in-memory list; each ``dict`` must expose ``id`` and text
(``content_with_weight`` / ``text``).
Returns ``{template_id: {"inserted", "updated", "duplicates_dropped"}}``.
Raises :class:`TaskCanceledException` when ``cancel_check`` trips.
"""
from api.apps.restful_apis.chunk_api import _compilation_template_kind
if not active_templates:
return {}
total = len(active_templates)
accumulators: dict[str, list[dict]] = {tid: [] for tid, _ in active_templates}
template_kinds: dict[str, str] = {tid: _compilation_template_kind((cfg or {}).get("kind")) for tid, cfg in active_templates}
agg_infos: dict[str, dict] = {tid: {"inserted": 0, "updated": 0, "duplicates_dropped": 0} for tid, _ in active_templates}
chunks_by_id: dict[str, str] = {}
async def _flush(template_id: str) -> None:
acc = accumulators[template_id]
if not acc:
return
kind = template_kinds.get(template_id, "")
if kind in CHAIN_KINDS:
try:
acc = await asyncio.wait_for(
validate_and_correct_chain(
acc,
chunks_by_id,
chat_mdl_by_tid[template_id],
kind,
callback=progress_cb,
),
timeout=STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S,
)
accumulators[template_id] = acc
except asyncio.TimeoutError:
logging.warning(
"chain validate: timed out after %ss for template %s; using uncorrected docs",
STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S,
template_id,
)
except Exception:
logging.exception(
"chain validate: unexpected failure for template %s; using uncorrected docs",
template_id,
)
info = await merge_compiled_structures(
acc,
chat_mdl_by_tid[template_id],
embedding_model,
tenant_id,
kb_id,
compilation_template_id=template_id,
cancel_check=cancel_check,
)
acc.clear()
if isinstance(info, dict):
agg = agg_infos[template_id]
for k in ("inserted", "updated", "duplicates_dropped"):
agg[k] = agg.get(k, 0) + int(info.get(k, 0) or 0)
progress_cb(msg=f"Start document knowledge compilation ({total} template(s)) ...")
batch_no = 0
async for batch in chunk_batches:
batch_no += 1
for chunk in batch:
cid = chunk.get("id")
if isinstance(cid, str) and cid not in chunks_by_id:
text = chunk.get("content_with_weight") or chunk.get("text") or ""
chunks_by_id[cid] = text if isinstance(text, str) else ""
for idx, (template_id, parser_cfg) in enumerate(active_templates):
progress_cb(msg=f" compile batch {batch_no} ({len(batch)} chunks) for template ({idx + 1}/{total})")
docs = await compile_structure_from_text(
batch,
parser_cfg,
chat_mdl_by_tid[template_id],
embedding_model,
doc_id,
language=language,
callback=progress_cb,
compilation_template_id=template_id,
)
if docs:
accumulators[template_id].extend(docs)
if len(accumulators[template_id]) >= DOC_STRUCTURE_MERGE_MAX_DOCS:
progress_cb(msg=f" merge flush ({len(accumulators[template_id])} docs) for template ({idx + 1}/{total})")
await _flush(template_id)
for idx, (template_id, parser_cfg) in enumerate(active_templates):
if cancel_check():
raise TaskCanceledException("Task was cancelled during document knowledge compilation")
await _flush(template_id)
agg = agg_infos[template_id]
if record:
record(f"document_structure_compile:{template_id}", agg)
progress_cb(msg=f"Document knowledge compilation done ({idx + 1}/{total}): {agg}")
# ── Synthesis phase ──────────────────────────────────────────────
# If the template has synthesis.enabled, run wiki PLAN+REFINE
# to generate output (wiki page, essence paragraph, etc.).
synthesis_cfg = (parser_cfg or {}).get("synthesis") or {}
if synthesis_cfg.get("enabled"):
example = synthesis_cfg.get("example")
compile_kwd = synthesis_cfg.get("compile_kwd", "artifact_page")
plan_cfg = synthesis_cfg.get("plan") or {}
# Reserved for future wiki_plan_from_reduction extension:
# entity_type_filter, mention_count_threshold, top_n
if plan_cfg:
logging.debug(
"synthesis: template %s plan config %r reserved for future use",
template_id, plan_cfg,
)
if cancel_check():
raise TaskCanceledException("Task was cancelled before synthesis PLAN")
if not example:
logging.warning(
"synthesis: template %s has synthesis.enabled but no example; skipping",
template_id,
)
else:
try:
from rag.advanced_rag.knowlege_compile.wiki import (
wiki_plan_from_reduction,
wiki_refine_from_plan,
)
progress_cb(msg=f"Synthesis PLAN for template {template_id} (kind={compile_kwd}) ...")
plan = await wiki_plan_from_reduction(
chat_mdl=chat_mdl_by_tid[template_id],
embd_mdl=embedding_model,
tenant_id=tenant_id,
kb_id=kb_id,
callback=progress_cb,
)
if cancel_check():
raise TaskCanceledException("Task was cancelled after synthesis PLAN")
if not plan or not plan.get("pages"):
progress_cb(msg=f"Synthesis: no pages planned for template {template_id}.")
else:
progress_cb(msg=f"Synthesis REFINE for template {template_id} ({len(plan['pages'])} page(s)) ...")
pages = await wiki_refine_from_plan(
chat_mdl=chat_mdl_by_tid[template_id],
embd_mdl=embedding_model,
tenant_id=tenant_id,
kb_id=kb_id,
callback=progress_cb,
example=example,
)
# Overwrite compile_kwd on every output page so the
# synthesis type is tracked correctly in ES.
for p in pages or []:
p["compile_kwd"] = compile_kwd
progress_cb(msg=f"Synthesis done: {len(pages or [])} {compile_kwd} page(s) written.")
except TaskCanceledException:
raise
except Exception:
logging.exception("synthesis: failed for template %s", template_id)
return agg_infos

View File

@@ -1630,6 +1630,9 @@ Description: {kb_description}
## Extracted concepts (with mention counts) ## Extracted concepts (with mention counts)
{concepts_summary} {concepts_summary}
## Extracted topics
{topics_summary}
## KB reconciliation results ## KB reconciliation results
{kb_reconciliation} {kb_reconciliation}
@@ -1642,6 +1645,7 @@ Produce a JSON compilation plan:
"slug": "concept/example-name", "slug": "concept/example-name",
"title": "Example Page Title", "title": "Example Page Title",
"page_type": "entity | concept | topic", "page_type": "entity | concept | topic",
"topic": "short canonical topic name",
"entity_names": ["entity or concept name covered by this page"], "entity_names": ["entity or concept name covered by this page"],
"related_kb_pages": ["existing-slug-1"], "related_kb_pages": ["existing-slug-1"],
"priority": 1 "priority": 1
@@ -1656,6 +1660,10 @@ Rules:
- For UPDATE, slug MUST be an existing wiki page slug from the KB - For UPDATE, slug MUST be an existing wiki page slug from the KB
reconciliation list above. reconciliation list above.
- page_type is one of: entity | concept | topic. Do NOT use "source". - page_type is one of: entity | concept | topic. Do NOT use "source".
- topic is required for every page. Prefer a topic from the extracted
topics implied by the entities/concepts. If none fits, create a short
canonical topic name in the user's language. For topic pages, topic should
usually match the page title.
# Slug format (CRITICAL — every slug must follow this shape exactly) # Slug format (CRITICAL — every slug must follow this shape exactly)
- The slug is ``<page_type>/<short-descriptive-name>``. The separator - The slug is ``<page_type>/<short-descriptive-name>``. The separator
@@ -1958,6 +1966,7 @@ async def _wiki_resolve_maybe_items(
async def _wiki_planning_call( async def _wiki_planning_call(
canonical_entities: list[dict], canonical_entities: list[dict],
canonical_concepts: list[dict], canonical_concepts: list[dict],
raw_topics: list,
reconciliation: dict[str, dict], reconciliation: dict[str, dict],
chat_mdl, chat_mdl,
kb_name: str | None, kb_name: str | None,
@@ -1981,6 +1990,9 @@ async def _wiki_planning_call(
entities_summary = "\n".join(_wiki_format_entity_for_plan(e, reconciliation) for e in sorted_entities[:200]) or " (none)" entities_summary = "\n".join(_wiki_format_entity_for_plan(e, reconciliation) for e in sorted_entities[:200]) or " (none)"
concepts_summary = "\n".join(_wiki_format_concept_for_plan(c, reconciliation) for c in sorted_concepts[:200]) or " (none)" concepts_summary = "\n".join(_wiki_format_concept_for_plan(c, reconciliation) for c in sorted_concepts[:200]) or " (none)"
topics_summary = "\n".join(
f" - {t.strip()}" for t in raw_topics[:200] if isinstance(t, str) and t.strip()
) or " (none)"
kb_lines: list[str] = [] kb_lines: list[str] = []
for name, rec in reconciliation.items(): for name, rec in reconciliation.items():
@@ -1993,6 +2005,7 @@ async def _wiki_planning_call(
kb_description=kb_description or "(no description)", kb_description=kb_description or "(no description)",
entities_summary=entities_summary, entities_summary=entities_summary,
concepts_summary=concepts_summary, concepts_summary=concepts_summary,
topics_summary=topics_summary,
kb_reconciliation=kb_reconciliation, kb_reconciliation=kb_reconciliation,
target_page_count=target_page_count, target_page_count=target_page_count,
) )
@@ -2013,6 +2026,13 @@ async def _wiki_planning_call(
return {"pages": [], "estimated_page_count": 0, "compilation_notes": "planner returned non-object"} return {"pages": [], "estimated_page_count": 0, "compilation_notes": "planner returned non-object"}
if "pages" not in res or not isinstance(res.get("pages"), list): if "pages" not in res or not isinstance(res.get("pages"), list):
res["pages"] = [] res["pages"] = []
for page in res["pages"]:
if not isinstance(page, dict):
continue
topic = page.get("topic")
if not isinstance(topic, str) or not topic.strip():
fallback = page.get("title") or page.get("slug") or page.get("page_type") or "General"
page["topic"] = str(fallback).strip() or "General"
if "estimated_page_count" not in res: if "estimated_page_count" not in res:
res["estimated_page_count"] = len(res["pages"]) res["estimated_page_count"] = len(res["pages"])
res.setdefault("compilation_notes", "") res.setdefault("compilation_notes", "")
@@ -2334,6 +2354,7 @@ async def wiki_plan_from_reduction(
plan = await _wiki_planning_call( plan = await _wiki_planning_call(
canonical_entities=canonical_entities, canonical_entities=canonical_entities,
canonical_concepts=canonical_concepts, canonical_concepts=canonical_concepts,
raw_topics=raw_topics,
reconciliation=reconciliation, reconciliation=reconciliation,
chat_mdl=chat_mdl, chat_mdl=chat_mdl,
kb_name=kb_name, kb_name=kb_name,
@@ -3277,7 +3298,7 @@ async def wiki_refine_from_plan(
callback: optional ``(progress: float, msg: str)`` callback. callback: optional ``(progress: float, msg: str)`` callback.
Returns the list of page dicts (one per planned entry). Each page dict Returns the list of page dicts (one per planned entry). Each page dict
has ``slug, title, page_type, action, content_md, summary, has ``slug, title, page_type, topic, action, content_md, summary,
entity_names, related_kb_pages, source_chunk_ids``. entity_names, related_kb_pages, source_chunk_ids``.
""" """
# Defensive: some callers accidentally pass the result of # Defensive: some callers accidentally pass the result of
@@ -3466,10 +3487,15 @@ async def wiki_refine_from_plan(
source_doc_ids = await _wiki_collect_doc_ids(source_chunk_ids, tenant_id, kb_id) source_doc_ids = await _wiki_collect_doc_ids(source_chunk_ids, tenant_id, kb_id)
summary = _wiki_extract_summary(content_md_rendered) or title summary = _wiki_extract_summary(content_md_rendered) or title
topic = plan_item.get("topic")
if not isinstance(topic, str) or not topic.strip():
topic = title or slug
page = { page = {
"slug": slug, "slug": slug,
"title": title, "title": title,
"page_type": page_type, "page_type": page_type,
"topic": topic.strip(),
"action": action, "action": action,
# Rendered content (with clickable artifact/{kb_id}/{slug} links) is # Rendered content (with clickable artifact/{kb_id}/{slug} links) is
# what callers and the UI consume; the raw [[slug]] form is # what callers and the UI consume; the raw [[slug]] form is

View File

@@ -0,0 +1,18 @@
#
# Copyright 2025 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 rag.flow.compiler.compiler import Compiler, CompilerParam
__all__ = ["Compiler", "CompilerParam"]

View File

@@ -0,0 +1,199 @@
#
# Copyright 2025 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.
import logging
import random
from copy import deepcopy
import xxhash
from agent.component.llm import LLMParam, LLM
from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance
from api.db.services.document_service import DocumentService
from api.db.services.llm_service import LLMBundle
from api.db.services.task_service import has_canceled
from common.constants import LLMType
from rag.advanced_rag.knowlege_compile.runner import (
DOC_STRUCTURE_COMPILE_BATCH_CHUNKS,
load_active_templates,
resolve_template_ids_from_groups,
run_structure_compile_over_batches,
split_tree_templates,
)
from rag.flow.base import ProcessBase, ProcessParamBase
class CompilerParam(ProcessParamBase, LLMParam):
"""Parameters for the knowledge-Compiler flow component.
Same LLM-backed shape as the Extractor, but instead of a single inline
``knowledge_compilation`` config it drives compilation from one or more
saved **compilation-template groups** (``compilation_template_group_ids``).
Each group resolves to a set of templates, each of which carries its own
structure-compilation config (kind, fields, synthesis, ...).
"""
def __init__(self):
super().__init__()
self.compilation_template_group_ids = []
def check(self):
super().check()
self.check_empty(self.compilation_template_group_ids, "Compilation Template Groups")
class Compiler(ProcessBase, LLM):
component_name = "Compiler"
def _compile_progress(self, prog=None, msg=""):
"""Adapt the knowledge-compile ``callback`` protocol to the flow
callback. Downstream compile helpers invoke the callback either as
``callback(prog, msg)`` (positional) or ``callback(msg=...)``; the
flow's ``self.callback`` expects ``(progress, message)``.
"""
self.callback(prog, msg)
def _compile_language(self, kwargs: dict) -> str:
language = kwargs.get("language") or getattr(self._canvas, "_language", None)
if isinstance(language, str):
language = language.strip()
if not language and getattr(self._canvas, "_doc_id", None):
config = DocumentService.get_chunking_config(self._canvas._doc_id) or {}
language = config.get("language")
if isinstance(language, str):
language = language.strip()
return language or "English"
async def _invoke(self, **kwargs):
self.set_output("output_format", "chunks")
self.callback(random.randint(1, 5) / 100.0, "Start knowledge compilation.")
# Collect the upstream chunk list (same contract as the Extractor).
inputs = self.get_input_elements()
chunks = []
for _, v in inputs.items():
val = v["value"]
if isinstance(val, list):
chunks = deepcopy(val)
tenant_id = self._canvas.get_tenant_id()
doc_id = self._canvas._doc_id
kb_id = getattr(self._canvas, "_kb_id", None) or DocumentService.get_knowledgebase_id(doc_id)
language = self._compile_language(kwargs)
if not chunks:
self.set_output("chunks", chunks)
return
for ck in chunks:
ck["doc_id"] = doc_id
ck["id"] = xxhash.xxh64((ck["text"] + str(ck["doc_id"])).encode("utf-8")).hexdigest()
# Resolve the configured template groups to concrete, active
# (non-artifact) structure-compilation templates.
template_ids = resolve_template_ids_from_groups(self._param.compilation_template_group_ids, tenant_id)
active_templates = load_active_templates(template_ids, tenant_id)
if not active_templates:
self.callback(msg="No active compilation templates resolved from the configured groups.")
self.set_output("chunks", chunks)
return
# Per-template chat model: a template may pin its own ``llm_id``;
# otherwise fall back to this component's configured chat model.
llm_bundle_cache: dict[str, LLMBundle] = {}
chat_mdl_by_tid: dict[str, LLMBundle] = {}
filtered_templates: list[tuple[str, dict]] = []
default_chat_mdl = None
for template_id, parser_cfg in active_templates:
tpl_llm_id = parser_cfg.get("llm_id") if isinstance(parser_cfg, dict) else None
if isinstance(tpl_llm_id, str) and tpl_llm_id.strip():
chat_llm_id = tpl_llm_id.strip()
if chat_llm_id not in llm_bundle_cache:
try:
cfg = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT, chat_llm_id)
llm_bundle_cache[chat_llm_id] = LLMBundle(
tenant_id,
cfg,
lang=language,
max_retries=self._param.max_retries,
retry_interval=self._param.delay_after_error,
)
except Exception:
logging.exception(
"Compiler: cannot resolve chat model %s for template %s; skipping",
chat_llm_id,
template_id,
)
continue
chat_mdl_by_tid[template_id] = llm_bundle_cache[chat_llm_id]
else:
if default_chat_mdl is None:
default_chat_mdl = LLMBundle(
tenant_id,
self.chat_mdl.model_config,
lang=language,
max_retries=self._param.max_retries,
retry_interval=self._param.delay_after_error,
)
chat_mdl_by_tid[template_id] = default_chat_mdl
filtered_templates.append((template_id, parser_cfg))
if not filtered_templates:
self.set_output("chunks", chunks)
return
active_templates = filtered_templates
embedding_model = LLMBundle(
tenant_id,
LLMType.EMBEDDING,
lang=language,
max_retries=self._param.max_retries,
retry_interval=self._param.delay_after_error,
)
tree_templates, non_tree_templates = split_tree_templates(active_templates)
if tree_templates:
# ``tree`` templates run RAPTOR over the whole document by
# reloading vectors from the doc store; that path is owned by the
# chunking task executor and isn't available from the flow.
logging.warning(
"Compiler: %d tree-kind template(s) are not supported in the flow pipeline; skipping",
len(tree_templates),
)
self.callback(msg=f"Skipping {len(tree_templates)} tree-kind template(s) (unsupported in flow).")
if non_tree_templates:
task_id = getattr(self._canvas, "task_id", None)
def _cancelled() -> bool:
return bool(task_id) and has_canceled(task_id)
async def _chunk_batches():
for i in range(0, len(chunks), DOC_STRUCTURE_COMPILE_BATCH_CHUNKS):
yield chunks[i:i + DOC_STRUCTURE_COMPILE_BATCH_CHUNKS]
await run_structure_compile_over_batches(
active_templates=non_tree_templates,
chat_mdl_by_tid=chat_mdl_by_tid,
embedding_model=embedding_model,
tenant_id=tenant_id,
kb_id=kb_id,
doc_id=doc_id,
language=language,
chunk_batches=_chunk_batches(),
progress_cb=self._compile_progress,
cancel_check=_cancelled,
)
self.set_output("chunks", chunks)

View File

@@ -0,0 +1,35 @@
#
# Copyright 2025 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 typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class CompilerFromUpstream(BaseModel):
created_time: float | None = Field(default=None, alias="_created_time")
elapsed_time: float | None = Field(default=None, alias="_elapsed_time")
name: str
file: dict | None = Field(default=None)
chunks: list[dict[str, Any]] | None = Field(default=None)
output_format: Literal["json", "markdown", "text", "html", "chunks"] | None = Field(default=None)
json_result: list[dict[str, Any]] | None = Field(default=None, alias="json")
markdown_result: str | None = Field(default=None, alias="markdown")
text_result: str | None = Field(default=None, alias="text")
html_result: str | None = Field(default=None, alias="html")
model_config = ConfigDict(populate_by_name=True, extra="forbid")

View File

@@ -17,16 +17,9 @@ import logging
import random import random
from copy import deepcopy from copy import deepcopy
from api.db.services.document_service import DocumentService
from api.db.services.llm_service import LLMBundle
from common.constants import LLMType
import xxhash import xxhash
from agent.component.llm import LLMParam, LLM from agent.component.llm import LLMParam, LLM
from rag.advanced_rag.knowlege_compile.structure import (
compile_structure_from_text,
merge_compiled_structures,
)
from rag.flow.base import ProcessBase, ProcessParamBase from rag.flow.base import ProcessBase, ProcessParamBase
from rag.prompts.generator import run_toc_from_text from rag.prompts.generator import run_toc_from_text
@@ -35,7 +28,6 @@ class ExtractorParam(ProcessParamBase, LLMParam):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.field_name = "" self.field_name = ""
self.knowledge_compilation = {}
def check(self): def check(self):
super().check() super().check()
@@ -82,20 +74,6 @@ class Extractor(ProcessBase, LLM):
return d return d
return None return None
async def _knowledge_compile(self, docs):
embedding_model = LLMBundle(self._canvas.get_tenant_id(), LLMType.EMBEDDING, max_retries=self._param.max_retries, retry_interval=self._param.delay_after_error)
self.callback(0.2, message="Start to generate table of content ...")
docs = sorted(
docs,
key=lambda d: (
d.get("page_num_int", 0)[0] if isinstance(d.get("page_num_int", 0), list) else d.get("page_num_int", 0),
d.get("top_int", 0)[0] if isinstance(d.get("top_int", 0), list) else d.get("top_int", 0),
),
)
docs = await compile_structure_from_text(docs, self._param.knowledge_compilation, self.chat_mdl, embedding_model, self._canvas._doc_id)
info = await merge_compiled_structures(docs, self.chat_mdl, embedding_model, self._canvas.get_tenant_id(), DocumentService.get_knowledgebase_id(self._canvas._doc_id))
return info
async def _invoke(self, **kwargs): async def _invoke(self, **kwargs):
self.set_output("output_format", "chunks") self.set_output("output_format", "chunks")
self.callback(random.randint(1, 5) / 100.0, "Start to generate.") self.callback(random.randint(1, 5) / 100.0, "Start to generate.")
@@ -118,13 +96,6 @@ class Extractor(ProcessBase, LLM):
chunks.append(toc) chunks.append(toc)
self.set_output("chunks", chunks) self.set_output("chunks", chunks)
return return
if self._param.field_name in ["set", "list", "graph"]:
for ck in chunks:
ck["doc_id"] = self._canvas._doc_id
ck["id"] = xxhash.xxh64((ck["text"] + str(ck["doc_id"])).encode("utf-8")).hexdigest()
await self._knowledge_compile(chunks)
self.set_output("chunks", chunks)
return
prog = 0 prog = 0
for i, ck in enumerate(chunks): for i, ck in enumerate(chunks):

View File

@@ -26,7 +26,7 @@ from rag.utils.redis_conn import REDIS_CONN
class Pipeline(Graph): class Pipeline(Graph):
def __init__(self, dsl: str | dict, tenant_id=None, doc_id=None, task_id=None, flow_id=None): def __init__(self, dsl: str | dict, tenant_id=None, doc_id=None, task_id=None, flow_id=None, language=None):
if isinstance(dsl, dict): if isinstance(dsl, dict):
dsl = json.dumps(dsl, ensure_ascii=False) dsl = json.dumps(dsl, ensure_ascii=False)
super().__init__(dsl, tenant_id, task_id) super().__init__(dsl, tenant_id, task_id)
@@ -34,6 +34,7 @@ class Pipeline(Graph):
doc_id = None doc_id = None
self._doc_id = doc_id self._doc_id = doc_id
self._flow_id = flow_id self._flow_id = flow_id
self._language = language
self._kb_id = None self._kb_id = None
if self._doc_id: if self._doc_id:
self._kb_id = DocumentService.get_knowledgebase_id(doc_id) self._kb_id = DocumentService.get_knowledgebase_id(doc_id)

View File

@@ -760,7 +760,14 @@ async def run_dataflow(task: dict):
assert e, "Pipeline log not found." assert e, "Pipeline log not found."
dsl = pipeline_log.dsl dsl = pipeline_log.dsl
dataflow_id = pipeline_log.pipeline_id dataflow_id = pipeline_log.pipeline_id
pipeline = Pipeline(dsl, tenant_id=task["tenant_id"], doc_id=doc_id, task_id=task_id, flow_id=dataflow_id) pipeline = Pipeline(
dsl,
tenant_id=task["tenant_id"],
doc_id=doc_id,
task_id=task_id,
flow_id=dataflow_id,
language=task.get("language"),
)
rag_tokenizer.tokenizer.set_language(task.get("language", "English")) rag_tokenizer.tokenizer.set_language(task.get("language", "English"))
chunks = await pipeline.run(file=task["file"]) if task.get("file") else await pipeline.run() chunks = await pipeline.run(file=task["file"]) if task.get("file") else await pipeline.run()
if doc_id == CANVAS_DEBUG_DOC_ID: if doc_id == CANVAS_DEBUG_DOC_ID:

View File

@@ -351,14 +351,10 @@ def count_with_key(docs: List[Dict], key: str) -> int:
import numpy as np # noqa: E402 import numpy as np # noqa: E402
from typing import Callable, Optional # noqa: E402 from typing import Callable, Optional # noqa: E402
from common.exceptions import TaskCanceledException # noqa: E402
from common.misc_utils import thread_pool_exec # noqa: E402 from common.misc_utils import thread_pool_exec # noqa: E402
from common.token_utils import num_tokens_from_string # noqa: E402 from common.token_utils import num_tokens_from_string # noqa: E402
from rag.nlp import search # noqa: E402 from rag.nlp import search # noqa: E402
from api.db.services.document_service import DocumentService # noqa: E402 from api.db.services.document_service import DocumentService # noqa: E402
from api.db.services.compilation_template_service import ( # noqa: E402
CompilationTemplateService,
)
from api.db.services.compilation_template_group_service import ( # noqa: E402 from api.db.services.compilation_template_group_service import ( # noqa: E402
CompilationTemplateGroupService, CompilationTemplateGroupService,
) )
@@ -368,32 +364,20 @@ from api.db.services.task_service import ( # noqa: E402
credit_doc_chunking_task, credit_doc_chunking_task,
is_doc_chunking_aborted, is_doc_chunking_aborted,
) )
from rag.advanced_rag.knowlege_compile.structure import ( # noqa: E402
CHAIN_KINDS,
compile_structure_from_text,
merge_compiled_structures,
validate_and_correct_chain,
)
# ----- tunables ------------------------------------------------------ # ----- tunables ------------------------------------------------------
# Bound how many source chunks are handed to a single # The structure-compile batching / merge-flush / chain-correction tunables
# ``compile_structure_from_text`` invocation. The call fans them out # and the non-tree compilation core moved to
# across max_workers internally, so a moderate window keeps memory + # ``rag.advanced_rag.knowlege_compile.runner`` so the ``rag.flow`` Compiler
# LLM-context pressure predictable for long docs. # component can share them. Re-exported here for backwards compatibility.
DOC_STRUCTURE_COMPILE_BATCH_CHUNKS = 4 from rag.advanced_rag.knowlege_compile.runner import ( # noqa: E402
DOC_STRUCTURE_COMPILE_BATCH_CHUNKS,
# Bound how many compiled ES-ready docs may accumulate before we flush DOC_STRUCTURE_MERGE_MAX_DOCS, # noqa: F401
# them through ``merge_compiled_structures``. The merger does pairwise STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S, # noqa: F401
# cosine + LLM duplicate-judging, so it's the more expensive step; we load_active_templates,
# cap the per-flush set to keep the local-dedup buckets tractable. run_structure_compile_over_batches,
DOC_STRUCTURE_MERGE_MAX_DOCS = 512 )
# Hard wall on the chain-validator LLM correction step. ``list`` and
# ``timeline`` kinds run this just before each merge flush; anything
# longer than this is treated as a blocked LLM and the uncorrected
# docs are flushed instead.
STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S = 120.0
# ----- parser_config helpers ----------------------------------------- # ----- parser_config helpers -----------------------------------------
@@ -945,27 +929,7 @@ async def run_document_structure_compile(handler, embedding_model: LLMBundle) ->
if not template_ids: if not template_ids:
return return
active_templates: list[tuple[str, dict]] = [] active_templates = load_active_templates(template_ids, ctx.tenant_id)
for template_id in template_ids:
template = CompilationTemplateService.get_saved(template_id, ctx.tenant_id)
if not template:
logging.warning(
"document_structure_compile: template %s not found",
template_id,
)
continue
parser_cfg = template.get("config") or {}
if not isinstance(parser_cfg, dict):
logging.warning(
"document_structure_compile: template %s config is invalid",
template_id,
)
continue
kind = _compilation_template_kind(parser_cfg.get("kind"))
if not kind or kind == "artifacts":
continue
active_templates.append((template_id, parser_cfg))
if not active_templates: if not active_templates:
return return
@@ -1018,177 +982,29 @@ async def run_document_structure_compile(handler, embedding_model: LLMBundle) ->
if not non_tree_templates: if not non_tree_templates:
return return
active_templates = non_tree_templates
progress_cb = ctx.progress_cb async def _stream_doc_batches():
total = len(active_templates) async for batch in handler._load_chunks_for_doc(
accumulators: dict[str, list[dict]] = {tid: [] for tid, _ in active_templates}
template_kinds: dict[str, str] = {tid: _compilation_template_kind((cfg or {}).get("kind")) for tid, cfg in active_templates}
agg_infos: dict[str, dict] = {tid: {"inserted": 0, "updated": 0, "duplicates_dropped": 0} for tid, _ in active_templates}
chunks_by_id: dict[str, str] = {}
async def _flush(template_id: str) -> None:
acc = accumulators[template_id]
if not acc:
return
kind = template_kinds.get(template_id, "")
if kind in CHAIN_KINDS:
try:
acc = await asyncio.wait_for(
validate_and_correct_chain(
acc,
chunks_by_id,
chat_mdl_by_tid[template_id],
kind,
callback=progress_cb,
),
timeout=STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S,
)
accumulators[template_id] = acc
except asyncio.TimeoutError:
logging.warning(
"chain validate: timed out after %ss for template %s; using uncorrected docs",
STRUCTURE_CHAIN_CORRECTION_TIMEOUT_S,
template_id,
)
except Exception:
logging.exception(
"chain validate: unexpected failure for template %s; using uncorrected docs",
template_id,
)
info = await merge_compiled_structures(
acc,
chat_mdl_by_tid[template_id],
embedding_model,
ctx.tenant_id, ctx.tenant_id,
ctx.kb_id, ctx.kb_id,
compilation_template_id=template_id, ctx.doc_id,
cancel_check=lambda: ctx.has_canceled_func(ctx.id), batch_size=DOC_STRUCTURE_COMPILE_BATCH_CHUNKS,
) ):
acc.clear() yield batch
if isinstance(info, dict):
agg = agg_infos[template_id]
for k in ("inserted", "updated", "duplicates_dropped"):
agg[k] = agg.get(k, 0) + int(info.get(k, 0) or 0)
progress_cb(msg=f"Start document knowledge compilation ({total} template(s)) ...") await run_structure_compile_over_batches(
active_templates=non_tree_templates,
batch_no = 0 chat_mdl_by_tid=chat_mdl_by_tid,
async for batch in handler._load_chunks_for_doc( embedding_model=embedding_model,
ctx.tenant_id, tenant_id=ctx.tenant_id,
ctx.kb_id, kb_id=ctx.kb_id,
ctx.doc_id, doc_id=ctx.doc_id,
batch_size=DOC_STRUCTURE_COMPILE_BATCH_CHUNKS, language=ctx.language,
): chunk_batches=_stream_doc_batches(),
batch_no += 1 progress_cb=ctx.progress_cb,
for chunk in batch: cancel_check=lambda: ctx.has_canceled_func(ctx.id),
cid = chunk.get("id") record=ctx.recording_context.record,
if isinstance(cid, str) and cid not in chunks_by_id: )
text = chunk.get("content_with_weight") or ""
chunks_by_id[cid] = text if isinstance(text, str) else ""
for idx, (template_id, parser_cfg) in enumerate(active_templates):
progress_cb(msg=f" compile batch {batch_no} ({len(batch)} chunks) for template ({idx + 1}/{total})")
docs = await compile_structure_from_text(
batch,
parser_cfg,
chat_mdl_by_tid[template_id],
embedding_model,
ctx.doc_id,
language=ctx.language,
callback=progress_cb,
compilation_template_id=template_id,
)
if docs:
accumulators[template_id].extend(docs)
if len(accumulators[template_id]) >= DOC_STRUCTURE_MERGE_MAX_DOCS:
progress_cb(msg=f" merge flush ({len(accumulators[template_id])} docs) for template ({idx + 1}/{total})")
await _flush(template_id)
for idx, (template_id, parser_cfg) in enumerate(active_templates):
if ctx.has_canceled_func(ctx.id):
raise TaskCanceledException(f"Task {ctx.id} was cancelled during document knowledge compilation")
await _flush(template_id)
agg = agg_infos[template_id]
ctx.recording_context.record(f"document_structure_compile:{template_id}", agg)
progress_cb(msg=f"Document knowledge compilation done ({idx + 1}/{total}): {agg}")
# ── Synthesis phase ──────────────────────────────────────────────
# If the template has synthesis.enabled, run wiki PLAN+REFINE
# to generate output (wiki page, essence paragraph, etc.).
synthesis_cfg = (parser_cfg or {}).get("synthesis") or {}
if synthesis_cfg.get("enabled"):
example = synthesis_cfg.get("example")
compile_kwd = synthesis_cfg.get("compile_kwd", "artifact_page")
plan_cfg = synthesis_cfg.get("plan") or {}
# Reserved for future wiki_plan_from_reduction extension:
# entity_type_filter, mention_count_threshold, top_n
if plan_cfg:
logging.debug(
"synthesis: template %s plan config %r reserved for future use",
template_id, plan_cfg,
)
if ctx.has_canceled_func(ctx.id):
raise TaskCanceledException(
f"Task {ctx.id} was cancelled before synthesis PLAN"
)
if not example:
logging.warning(
"synthesis: template %s has synthesis.enabled but no example; skipping",
template_id,
)
else:
try:
from rag.advanced_rag.knowlege_compile.wiki import (
wiki_plan_from_reduction,
wiki_refine_from_plan,
)
progress_cb(
msg=f"Synthesis PLAN for template {template_id} (kind={compile_kwd}) ..."
)
plan = await wiki_plan_from_reduction(
chat_mdl=chat_mdl_by_tid[template_id],
embd_mdl=embedding_model,
tenant_id=ctx.tenant_id,
kb_id=ctx.kb_id,
callback=progress_cb,
)
if ctx.has_canceled_func(ctx.id):
raise TaskCanceledException(
f"Task {ctx.id} was cancelled after synthesis PLAN"
)
if not plan or not plan.get("pages"):
progress_cb(
msg=f"Synthesis: no pages planned for template {template_id}."
)
else:
progress_cb(
msg=f"Synthesis REFINE for template {template_id} ({len(plan['pages'])} page(s)) ..."
)
pages = await wiki_refine_from_plan(
chat_mdl=chat_mdl_by_tid[template_id],
embd_mdl=embedding_model,
tenant_id=ctx.tenant_id,
kb_id=ctx.kb_id,
callback=progress_cb,
example=example,
)
# Overwrite compile_kwd on every output page so the
# synthesis type is tracked correctly in ES.
for p in pages or []:
p["compile_kwd"] = compile_kwd
progress_cb(
msg=f"Synthesis done: {len(pages or [])} {compile_kwd} page(s) written."
)
except Exception:
logging.exception(
"synthesis: failed for template %s", template_id,
)
async def run_document_post_chunking_if_last( async def run_document_post_chunking_if_last(

View File

@@ -109,7 +109,14 @@ class DataflowService:
dataflow_id = corrected_id dataflow_id = corrected_id
# Run pipeline # Run pipeline
pipeline = Pipeline(dsl, tenant_id=ctx.tenant_id, doc_id=doc_id, task_id=task_id, flow_id=dataflow_id) pipeline = Pipeline(
dsl,
tenant_id=ctx.tenant_id,
doc_id=doc_id,
task_id=task_id,
flow_id=dataflow_id,
language=ctx.language,
)
chunks = await pipeline.run(file=ctx.file) if ctx.file else await pipeline.run() chunks = await pipeline.run(file=ctx.file) if ctx.file else await pipeline.run()
if doc_id == CANVAS_DEBUG_DOC_ID: if doc_id == CANVAS_DEBUG_DOC_ID:

View File

@@ -24,9 +24,9 @@ methods and one ``_run_wiki`` orchestrator. The public entry point is
The pipeline runs MAP per (doc, template) — each MAP call resumes from The pipeline runs MAP per (doc, template) — each MAP call resumes from
its own ``artifact_map_extract`` ES rows — then REDUCE / PLAN / REFINE its own ``artifact_map_extract`` ES rows — then REDUCE / PLAN / REFINE
KB-wide via ``rag.advanced_rag.knowlege_compile.wiki``. Refined pages KB-wide via ``rag.advanced_rag.knowlege_compile.wiki``. Refined pages
land in ES twice: once as searchable ``artifact_page`` rows and once as land in ES as searchable ``artifact_page`` rows, one
``artifact_entity`` / ``artifact_relation`` rows for the dataset ``artifact_page_topic`` row per topic, and ``artifact_entity`` /
Artifact tab's canvas graph. ``artifact_relation`` rows for the dataset Artifact tab's canvas graph.
Design notes: Design notes:
@@ -46,6 +46,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import re
from typing import AsyncIterator, Callable, Dict, List, Optional from typing import AsyncIterator, Callable, Dict, List, Optional
import xxhash import xxhash
@@ -83,6 +84,8 @@ WIKI_GRAPH_MAX_CHUNK_IDS_PER_NODE = 64
# path, which carries the user's own title/comments). # path, which carries the user's own title/comments).
WIKI_REGEN_COMMIT_TITLE = "Regenerated by artifact compilation" WIKI_REGEN_COMMIT_TITLE = "Regenerated by artifact compilation"
WIKI_REGEN_COMMIT_COMMENTS_TEMPLATE = "Auto-update via run_wiki (action={action})" WIKI_REGEN_COMMIT_COMMENTS_TEMPLATE = "Auto-update via run_wiki (action={action})"
WIKI_PAGE_COMPILE_KWD = "artifact_page"
WIKI_PAGE_TOPIC_COMPILE_KWD = "artifact_page_topic"
# ----- helpers ------------------------------------------------------- # ----- helpers -------------------------------------------------------
@@ -109,6 +112,95 @@ def _parser_config_compilation_template_ids(parser_config, tenant_id: str) -> li
return template_ids return template_ids
def _wiki_topic_from_page(page: Dict, fallback: str = "") -> str:
for key in ("topic", "title", "page_type"):
value = page.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return fallback.strip()
def _wiki_topic_slug(topic: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")
if not slug:
slug = xxhash.xxh64(topic.encode("utf-8", "surrogatepass")).hexdigest()[:12]
return f"topic/{slug[:80]}"
async def _ensure_wiki_topic_rows(
ctx: TaskContext,
index: str,
kb_id_str: str,
topics_by_name: dict[str, str],
) -> None:
if not topics_by_name:
return
from common.doc_store.doc_store_base import OrderByExpr
from rag.nlp import rag_tokenizer
topics = list(topics_by_name.keys())
existing: set[str] = set()
try:
res = await thread_pool_exec(
settings.docStoreConn.search,
["id", "topic_kwd"],
[],
{
"compile_kwd": [WIKI_PAGE_TOPIC_COMPILE_KWD],
"topic_kwd": topics,
},
[],
OrderByExpr(),
0,
max(len(topics), 1),
index,
[ctx.kb_id],
)
field_map = settings.docStoreConn.get_fields(res, ["id", "topic_kwd"])
for row in (field_map or {}).values():
raw = row.get("topic_kwd")
if isinstance(raw, list):
existing.update(t for t in raw if isinstance(t, str) and t)
elif isinstance(raw, str) and raw:
existing.add(raw)
except Exception:
logging.exception(
"wiki_persist: topic existence read failed for kb=%s; inserting stable topic ids",
kb_id_str,
)
rows: list[dict] = []
for topic, slug in topics_by_name.items():
if topic in existing:
continue
topic_id = xxhash.xxh64(
f"{kb_id_str}:{WIKI_PAGE_TOPIC_COMPILE_KWD}:{topic}".encode(
"utf-8",
"surrogatepass",
),
).hexdigest()
content_ltks = rag_tokenizer.tokenize(topic)
rows.append(
{
"id": topic_id,
"kb_id": kb_id_str,
"doc_id": kb_id_str,
"compile_kwd": WIKI_PAGE_TOPIC_COMPILE_KWD,
"topic_kwd": topic,
"title_kwd": topic,
"slug_kwd": slug,
"content_with_weight": topic,
"content_ltks": content_ltks,
"content_sm_ltks": rag_tokenizer.fine_grained_tokenize(content_ltks),
"available_int": 1,
}
)
if rows:
await thread_pool_exec(settings.docStoreConn.insert, rows, index, ctx.kb_id)
# ----- persistence --------------------------------------------------- # ----- persistence ---------------------------------------------------
@@ -125,6 +217,7 @@ async def persist_wiki_pages_to_es(
slug_kwd page.slug slug_kwd page.slug
title_kwd page.title title_kwd page.title
page_type_kwd page.page_type page_type_kwd page.page_type
topic_kwd page.topic
entity_names_kwd page.entity_names entity_names_kwd page.entity_names
outlinks_kwd page.outlinks outlinks_kwd page.outlinks
related_kb_pages_kwd page.related_kb_pages related_kb_pages_kwd page.related_kb_pages
@@ -163,7 +256,7 @@ async def persist_wiki_pages_to_es(
settings.docStoreConn.search, settings.docStoreConn.search,
["id", "slug_kwd", "content_with_weight"], ["id", "slug_kwd", "content_with_weight"],
[], [],
{"compile_kwd": ["artifact_page"], "slug_kwd": list(target_slugs)}, {"compile_kwd": [WIKI_PAGE_COMPILE_KWD], "slug_kwd": list(target_slugs)},
[], [],
OrderByExpr(), OrderByExpr(),
0, 0,
@@ -213,11 +306,13 @@ async def persist_wiki_pages_to_es(
return return
rows: List[Dict] = [] rows: List[Dict] = []
topics_by_name: dict[str, str] = {}
for page, vec in zip(pages, embeddings): for page, vec in zip(pages, embeddings):
slug = page.get("slug") or "" slug = page.get("slug") or ""
if not slug: if not slug:
continue continue
title = page.get("title") or slug title = page.get("title") or slug
topic = _wiki_topic_from_page(page, title)
summary = page.get("summary") or "" summary = page.get("summary") or ""
content_md = page.get("content_md_rendered") or page.get("content_md") or page.get("content_md_raw") or "" content_md = page.get("content_md_rendered") or page.get("content_md") or page.get("content_md_raw") or ""
@@ -242,10 +337,11 @@ async def persist_wiki_pages_to_es(
"id": row_id, "id": row_id,
"kb_id": kb_id_str, "kb_id": kb_id_str,
"doc_id": kb_id_str, # sentinel; KB-scoped row, real provenance in source_doc_ids "doc_id": kb_id_str, # sentinel; KB-scoped row, real provenance in source_doc_ids
"compile_kwd": "artifact_page", "compile_kwd": WIKI_PAGE_COMPILE_KWD,
"slug_kwd": slug, "slug_kwd": slug,
"title_kwd": title, "title_kwd": title,
"page_type_kwd": page.get("page_type") or "concept", "page_type_kwd": page.get("page_type") or "concept",
"topic_kwd": topic,
"entity_names_kwd": list(page.get("entity_names") or []), "entity_names_kwd": list(page.get("entity_names") or []),
"outlinks_kwd": list(page.get("outlinks") or []), "outlinks_kwd": list(page.get("outlinks") or []),
"outlinks_int": len(list(page.get("outlinks") or [])), "outlinks_int": len(list(page.get("outlinks") or [])),
@@ -263,6 +359,8 @@ async def persist_wiki_pages_to_es(
"available_int": 1, "available_int": 1,
} }
) )
if topic:
topics_by_name.setdefault(topic, _wiki_topic_slug(topic))
if not rows: if not rows:
return return
@@ -277,6 +375,15 @@ async def persist_wiki_pages_to_es(
) )
return return
if topics_by_name:
try:
await _ensure_wiki_topic_rows(ctx, index, kb_id_str, topics_by_name)
except Exception:
logging.exception(
"wiki_persist: topic row insert failed for kb=%s",
kb_id_str,
)
# Audit trail: one ArtifactCommit row per page whose rendered # Audit trail: one ArtifactCommit row per page whose rendered
# content actually changed (record_edit silently skips empty diffs). # content actually changed (record_edit silently skips empty diffs).
# Best-effort — commit failures log but don't fail the artifact # Best-effort — commit failures log but don't fail the artifact