diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a20cf2a..e323bec2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Changelog
+## [2.0.2]
+
+### Added
+- **The Migration Assistant reports graph data-integrity health.** Alongside the query check, it now shows how many vertices are missing embeddings (by type) and how many communities have a placeholder or empty summary.
+- **Targeted regeneration from the Migration Assistant.** Missing embeddings can be re-embedded, and communities with placeholder/empty summaries can be re-summarized, for just the affected items — without running a full rebuild. Items whose source content is unusable are reported as needing a rebuild.
+
+### Changed
+- **Agentic tool-calling support is detected at runtime.** Whether the chat model can drive Agentic mode is now confirmed by a lightweight runtime probe (checked once and cached in memory, re-checked after a restart) instead of a fixed model list, so current and future tool-calling models — including new providers — enable Agentic mode automatically, and a model that can't tool-call falls back to the classic engine until its configuration changes.
+- **The planned agent falls back to document search when a structured query returns nothing.** If the planned agent's structured query returns no rows, it now runs a hybrid document search before answering, so an empty structured result no longer produces a non-answer; it follows the same fallback setting as the classic engine.
+- **Clearer message when an answer can't be generated.** When the assistant is unable to produce an answer, it now suggests rephrasing the question to be more specific and contacting an administrator if the problem continues, instead of a bare failure notice.
+- **Token cost rates can be set per service.** LLM Config accepts optional input/output USD-per-1M-token rates for the completion, chatbot, and multimodal services; when set, Est. Cost uses them so cost is accurate for models the pricing library doesn't recognize (otherwise it keeps the library's pricing).
+
+### Fixed
+- **Knowledge-graph rebuild no longer fails on document names containing parentheses.** Document names with `(` or `)` are normalized without dropping the rest of the name, so rebuilds complete instead of erroring while creating chunks.
+- **PDF chart labels and table numbers survive extraction.** Picture-text blocks are kept intact, stacked and comma-grouped chart values are no longer glued together, and pages with unreadable embedded fonts are recovered from a page image — so figures and tables reach the graph with their numbers intact.
+- **Newer Gemini models are available in agentic chat.** Gemini 3.x and later families are recognized as tool-calling models, so agentic mode works with them instead of silently falling back to classic chat.
+- **Chat Stop button behaves correctly.** Stopping a response just as it finishes no longer sends a stray empty message, and the Stop button is the right size in the light theme.
+
## [2.0.1]
### Changed
diff --git a/README.md b/README.md
index 70aa1ca5..2b7f6fa8 100644
--- a/README.md
+++ b/README.md
@@ -515,7 +515,7 @@ Copy the below code into `configs/server_config.json`. You shouldn’t need to c
| `retrieval_include_entity` | bool \| null | `null` (auto) | Whether retriever queries include the generic `Entity` vertex alongside domain types. When unset, the server uses `false` if a domain schema exists and `true` otherwise. Set explicitly to override. |
| `schema_max_sample_files` | int | `5` | Maximum number of sample documents accepted by the *Generate from sample documents* path on the *Initialize Knowledge Graph* dialog. |
| `schema_max_total_mb` | int | `50` | Combined upload cap (MB) across all sample files for schema extraction. Bounds the content sent to the LLM. A single file may use the full budget; no separate per-file cap. |
-| `enable_router_fallback` | bool | `true` | When the function-call or Cypher path fails after 3 retries, fall back to vector search instead of failing the query. |
+| `enable_router_fallback` | bool | `true` | When a structured query returns no result, fall back to vector search instead of failing the query. Applies to the classic engine (after the function-call or Cypher path fails its retries) and to the planned agent (when a structural query returns no rows). |
| `chunker_config` | object | `{}` | Chunker-specific settings (see sub-parameters below). All settings are saved regardless of which chunker is selected as default. |
| ↳ `chunk_size` | int | `2048` | Maximum number of characters per chunk. Used by `character`, `markdown`, `html`, and `recursive` chunkers. Larger values produce fewer, bigger chunks; smaller values produce more, finer-grained chunks. |
| ↳ `overlap_size` | int | 1/8 of `chunk_size` | Number of overlapping characters between consecutive chunks. Used by `character`, `markdown`, `html`, and `recursive` chunkers. More overlap preserves cross-chunk context but increases total chunk count. Set to `0` for no overlap. |
diff --git a/VERSION b/VERSION
index 10bf840e..e9307ca5 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.0.1
\ No newline at end of file
+2.0.2
diff --git a/common/chunkers/structured.py b/common/chunkers/structured.py
index aa3ec26e..0980140d 100644
--- a/common/chunkers/structured.py
+++ b/common/chunkers/structured.py
@@ -140,15 +140,28 @@ class Element:
# pymupdf4llm artifacts:
# • "==> picture [WxH] intentionally omitted <==" — image dropped (skip line)
# • "----- Start of picture text -----" / "----- End of picture text -----"
-# bracket OCR'd content inside an image; we fold the body into the figure
-# so chart-internal labels stay with the image chunk.
+# (markdown) or ```` (HTML-comment form)
+# bracket pymupdf4llm picture-text inside a figure; we fold the body into
+# the figure so chart-internal labels stay with the image chunk.
_MD_PICTURE_OMITTED = re.compile(r"^\s*\*+\s*==>\s*picture\b.*intentionally omitted\s*<==\s*\*+.*$", re.IGNORECASE)
-_MD_PICTURE_TEXT_START = re.compile(r"^\s*\*+\s*-+\s*Start of picture text\s*-+\s*\*+\s*( )?\s*$", re.IGNORECASE)
-_MD_PICTURE_TEXT_END = re.compile(r"^\s*\*+\s*-+\s*End of picture text\s*-+\s*\*+\s*( )?\s*$", re.IGNORECASE)
+_MD_PICTURE_TEXT_START = re.compile(
+ r"^\s*(?:\*+\s*-+\s*Start of picture text\s*-+\s*\*+|"
+ r")\s*(?: )?\s*$",
+ re.IGNORECASE,
+)
+_MD_PICTURE_TEXT_END = re.compile(
+ r"^\s*(?:\*+\s*-+\s*End of picture text\s*-+\s*\*+|"
+ r")\s*(?: )?\s*$",
+ re.IGNORECASE,
+)
# Inline variant of the End marker: the picture-text body can arrive as a
# single -joined line with the marker on its tail, so it is not always
# line-anchored. Searched anywhere in a line to terminate the block.
-_MD_PICTURE_TEXT_END_INLINE = re.compile(r"\*+\s*-+\s*End of picture text\s*-+\s*\*+\s*(?: )?", re.IGNORECASE)
+_MD_PICTURE_TEXT_END_INLINE = re.compile(
+ r"(?:\*+\s*-+\s*End of picture text\s*-+\s*\*+|"
+ r")\s*(?: )?",
+ re.IGNORECASE,
+)
def _flush_prose(buf: List[str], heading: Optional[str], page: Optional[int], out: List[Element]) -> None:
@@ -312,18 +325,14 @@ def markdown_to_elements(md: str, page: Optional[int] = None) -> List[Element]:
i += 1
continue
- # 5b. Other HTML comments (chunk markers etc.) — skip.
- if _MD_HTML_COMMENT.match(line):
- i += 1
- continue
-
# 5c. pymupdf4llm "==> picture ... intentionally omitted <==" — drop.
if _MD_PICTURE_OMITTED.match(line):
i += 1
continue
- # 5d. pymupdf4llm picture-text block: ----- Start ... End of picture
- # text ----- wraps OCR'd content (chart axis labels, legends).
+ # 5d. pymupdf4llm picture-text block (markdown dash markers OR HTML
+ # comments). Must run before the generic HTML-comment skip so
+ # ```` is not dropped.
# Fold the body into the immediately preceding figure when
# present so chart-internal text travels with the image.
if _MD_PICTURE_TEXT_START.match(line):
@@ -361,6 +370,12 @@ def markdown_to_elements(md: str, page: Optional[int] = None) -> List[Element]:
out.append(Element(kind="figure", text=body, heading=heading, page=page))
continue
+ # 5b. Other HTML comments (chunk markers etc.) — skip.
+ # Picture-text comments are handled above.
+ if _MD_HTML_COMMENT.match(line):
+ i += 1
+ continue
+
# 6. Blank line — flush current prose paragraph.
if not stripped:
_flush_prose(prose_buf, heading, page, out)
diff --git a/common/config.py b/common/config.py
index d229d694..d6144ca5 100644
--- a/common/config.py
+++ b/common/config.py
@@ -187,8 +187,13 @@ def resolve_llm_services(llm_cfg: dict) -> dict:
embedding_provider = embedding.get("embedding_model_service", "").lower()
completion_provider = completion.get("llm_service", "").lower()
if embedding_provider and embedding_provider == completion_provider:
- # Identity/schema keys that belong to the embedding service itself
- embedding_own_keys = {"embedding_model_service", "model_name", "authentication_configuration", "token_limit"}
+ # Identity/schema keys that belong to the embedding service itself.
+ # Token-cost rates stay per-service too — completion's per-1M rates
+ # must not be applied to embedding usage, which is priced separately.
+ embedding_own_keys = {
+ "embedding_model_service", "model_name", "authentication_configuration",
+ "token_limit", "input_cost_per_1m", "output_cost_per_1m",
+ }
for k, v in completion.items():
if k not in embedding_own_keys and k not in embedding:
embedding[k] = v
diff --git a/common/db/health.py b/common/db/health.py
new file mode 100644
index 00000000..f1c72882
--- /dev/null
+++ b/common/db/health.py
@@ -0,0 +1,88 @@
+"""Data-integrity health checks for the Migration Assistant: embedding coverage
+and community-summary completeness.
+
+Deterministic, read-only — NO LLM calls and NO embedding-service init. They only
+run count queries + a schema check over an existing connection, so they are safe
+on a polled/triggered status check. The connection is the synchronous one the
+status endpoint already holds.
+"""
+
+import logging
+
+from common.utils.summary_placeholders import PLACEHOLDER_MARKERS
+
+logger = logging.getLogger(__name__)
+
+_VECTOR_ATTR = "embedding"
+
+
+def _has_vector_attr(conn, v_type: str) -> bool:
+ """True if *v_type* carries the embedding vector attribute, per the live
+ schema. Uses the connection's schema API — no embedding-service init."""
+ # Native vector attributes are exposed under ``EmbeddingAttributes`` in the
+ # schema, not by getVertexAttrs (which omits them).
+ try:
+ for v in conn.getSchema().get("VertexTypes", []):
+ if v.get("Name") == v_type:
+ return any(
+ e.get("Name") == _VECTOR_ATTR
+ for e in (v.get("EmbeddingAttributes") or [])
+ )
+ return False
+ except Exception:
+ return False
+
+
+def embeddable_types(conn) -> list[str]:
+ """Vertex types that carry the embedding vector attribute. Schema-detected
+ (not hardcoded) so it stays correct as the embedded set changes."""
+ # Native vector attributes are NOT returned by getVertexAttrs/getVertexTypes;
+ # they live under each vertex type's ``EmbeddingAttributes`` in the schema.
+ try:
+ schema = conn.getSchema()
+ except Exception as e:
+ logger.warning(f"embeddable_types: getSchema failed: {e}")
+ return []
+ out = []
+ for v in schema.get("VertexTypes", []):
+ if any(e.get("Name") == _VECTOR_ATTR for e in (v.get("EmbeddingAttributes") or [])):
+ out.append(v.get("Name"))
+ return out
+
+
+def embedding_coverage(conn, v_type: str) -> dict | None:
+ """``{"total": M, "missing": N}`` for *v_type*, or ``None`` when the type is
+ not embeddable or the ``vertices_have_embedding`` query is unavailable."""
+ try:
+ if not _has_vector_attr(conn, v_type):
+ return None
+ res = conn.runInstalledQuery(
+ "vertices_have_embedding", params={"vertex_type": v_type}
+ )
+ # PRINT order: [0] all_have_embedding, [1] size (missing), [2] total.
+ missing = int(res[1]["size"])
+ total = int(res[2]["total"])
+ return {"total": total, "missing": missing}
+ except Exception as e:
+ logger.warning(f"embedding_coverage({v_type}) failed: {e}")
+ return None
+
+
+def community_summary_health(conn, markers=None) -> dict | None:
+ """``{"total": M, "needs_resummarize": N}`` for Community vertices, or
+ ``None`` when the ``communities_need_resummarize`` query is unavailable.
+ ``needs_resummarize`` counts communities whose description is empty or a
+ known placeholder (old or new)."""
+ markers = markers if markers is not None else PLACEHOLDER_MARKERS
+ try:
+ res = conn.runInstalledQuery(
+ "communities_need_resummarize", params={"markers": markers}
+ )
+ row = res[0]
+ return {
+ "total": int(row["total"]),
+ "needs_resummarize": int(row["needs_resummarize"]),
+ }
+ except Exception as e:
+ logger.warning(f"community_summary_health failed: {e}")
+ return None
diff --git a/common/db/query_sets.py b/common/db/query_sets.py
index 1852bd52..18f1ce57 100644
--- a/common/db/query_sets.py
+++ b/common/db/query_sets.py
@@ -37,6 +37,7 @@
"common/gsql/graphrag/louvain/stream_community",
"common/gsql/graphrag/get_community_children",
"common/gsql/graphrag/communities_have_desc",
+ "common/gsql/graphrag/communities_need_resummarize",
"common/gsql/graphrag/graphrag_delete_all_communities",
"common/gsql/graphrag/graphrag_stream_entity_community_pairs",
"common/gsql/graphrag/graphrag_stream_all_ids",
@@ -69,6 +70,13 @@
"common/gsql/supportai/Check_Nonexistent_Vertices",
]
+# Data-integrity health-check queries used by the Migration Assistant panel.
+# vertices_have_embedding is also installed by the embedding store; listing it
+# here lets the Migration Assistant verify/repair it too.
+HEALTH_QUERIES = [
+ "common/gsql/vector/vertices_have_embedding",
+]
+
# What the Migration Assistant verifies for a GraphRAG graph: everything a
# GraphRAG graph actually installs. Excludes the opt-in ECC-checker queries.
MIGRATION_QUERIES = (
@@ -76,6 +84,7 @@
+ GRAPHRAG_COMMUNITY_QUERIES
+ SUPPORTAI_INIT_QUERIES
+ SUPPORTAI_RETRIEVER_QUERIES
+ + HEALTH_QUERIES
)
diff --git a/common/embeddings/tigergraph_embedding_store.py b/common/embeddings/tigergraph_embedding_store.py
index bfd1978a..47dbed8e 100644
--- a/common/embeddings/tigergraph_embedding_store.py
+++ b/common/embeddings/tigergraph_embedding_store.py
@@ -566,13 +566,14 @@ def retrieve_similar_with_score(self, query_embedding, top_k=10, similarity_thre
logger.info(f"Fetch {top_k} similar entries from {vertex_types} with filter {filter_expr}")
start_time = time()
+ # GSQL declares STRING expr=""; pyTigerGraph rejects null.
verts = self.conn.runInstalledQuery(
"get_topk_similar",
params={
"vertex_types": vertex_types,
"query_vector": query_embedding,
"top_k": top_k*2,
- "expr": filter_expr,
+ "expr": filter_expr or "",
}
)
end_time = time()
diff --git a/common/gsql/graphrag/communities_need_resummarize.gsql b/common/gsql/graphrag/communities_need_resummarize.gsql
new file mode 100644
index 00000000..9b24cf92
--- /dev/null
+++ b/common/gsql/graphrag/communities_need_resummarize.gsql
@@ -0,0 +1,24 @@
+CREATE OR REPLACE DISTRIBUTED QUERY communities_need_resummarize(SET markers, BOOL p=False) SYNTAX V2{
+ // A community needs re-summarization when its description is empty or is a
+ // known placeholder marker (old or new). ``markers`` is passed in so the
+ // set of placeholders stays defined in one place in Python.
+ SumAccum @@needs;
+ SumAccum @@total;
+ Comms = {Community.*};
+ Comms = SELECT c FROM Comms:c
+ POST-ACCUM
+ @@total += 1,
+ IF length(c.description) == 0 OR (c.description IN markers) THEN
+ @@needs += 1
+ END;
+
+ PRINT @@needs AS needs_resummarize, @@total AS total;
+
+ // p=true streams the affected community ids + their Louvain layer so the
+ // regenerate action can re-summarize exactly those.
+ IF p THEN
+ bad = SELECT c FROM Comms:c
+ WHERE length(c.description) == 0 OR (c.description IN markers);
+ PRINT bad[bad.iteration AS iteration] AS results;
+ END;
+}
diff --git a/common/gsql/vector/vertices_have_embedding.gsql b/common/gsql/vector/vertices_have_embedding.gsql
index ed6e39f1..5b8f99cd 100644
--- a/common/gsql/vector/vertices_have_embedding.gsql
+++ b/common/gsql/vector/vertices_have_embedding.gsql
@@ -5,7 +5,8 @@ CREATE OR REPLACE DISTRIBUTED QUERY vertices_have_embedding(String vertex_type,
PRINT (results.size() == 0) as all_have_embedding;
PRINT results.size() as size;
-
+ PRINT vset.size() as total;
+
IF verbose THEN
PRINT results[results.id as id] as results;
END;
diff --git a/common/llm_services/base_llm.py b/common/llm_services/base_llm.py
index e2f2ad3d..fe77ac1f 100644
--- a/common/llm_services/base_llm.py
+++ b/common/llm_services/base_llm.py
@@ -15,6 +15,7 @@
import os
import re
import logging
+from typing import Optional
from langchain_core.output_parsers import BaseOutputParser, PydanticOutputParser
from langchain_core.exceptions import OutputParserException
from langchain_core.prompts import BasePromptTemplate
@@ -126,7 +127,53 @@ def reset_usage_collection():
_usage_collector.set(None)
-def _record_usage(caller_name: str, usage_data: dict):
+def _parse_cost_rate(value) -> Optional[float]:
+ """Return a non-negative float rate, or None if unset/invalid."""
+ if value is None or value == "":
+ return None
+ try:
+ rate = float(value)
+ except (TypeError, ValueError):
+ return None
+ if rate < 0:
+ return None
+ return rate
+
+
+def estimate_cost_from_config(
+ config: Optional[dict],
+ input_tokens: int,
+ output_tokens: int,
+) -> Optional[float]:
+ """USD cost from user-configured per-1M rates, or None if not configured.
+
+ Both ``input_cost_per_1m`` and ``output_cost_per_1m`` must be set on
+ ``config`` (USD per 1M tokens). When present they always override
+ LangChain's built-in cost.
+ """
+ if not config:
+ return None
+ inp_rate = _parse_cost_rate(config.get("input_cost_per_1m"))
+ out_rate = _parse_cost_rate(config.get("output_cost_per_1m"))
+ if inp_rate is None or out_rate is None:
+ return None
+ return (
+ max(0, int(input_tokens or 0)) * inp_rate / 1_000_000.0
+ + max(0, int(output_tokens or 0)) * out_rate / 1_000_000.0
+ )
+
+
+def _record_usage(caller_name: str, usage_data: dict, config: Optional[dict] = None):
+ # User-configured rates replace LangChain cost entirely; otherwise keep
+ # whatever LangChain reported (may be 0 for unknown models).
+ user_cost = estimate_cost_from_config(
+ config,
+ usage_data.get("input_tokens", 0),
+ usage_data.get("output_tokens", 0),
+ )
+ if user_cost is not None:
+ usage_data["cost"] = user_cost
+ logger.info(f"{caller_name} usage: {usage_data}")
bucket = _usage_collector.get()
if bucket is not None:
bucket.append({"caller_name": caller_name, **usage_data})
@@ -500,8 +547,7 @@ def invoke_with_parser(
usage_data["output_tokens"] = cb.completion_tokens
usage_data["total_tokens"] = cb.total_tokens
usage_data["cost"] = cb.total_cost
- logger.info(f"{caller_name} usage: {usage_data}")
- _record_usage(caller_name, usage_data)
+ _record_usage(caller_name, usage_data, self.config)
raw_text = self._message_text(raw_output)
@@ -545,8 +591,7 @@ def invoke_with_tools(
usage_data["output_tokens"] = cb.completion_tokens
usage_data["total_tokens"] = cb.total_tokens
usage_data["cost"] = cb.total_cost
- logger.info(f"{caller_name} usage: {usage_data}")
- _record_usage(caller_name, usage_data)
+ _record_usage(caller_name, usage_data, self.config)
return resp
def invoke_structured(
@@ -579,8 +624,7 @@ def invoke_structured(
usage_data["output_tokens"] = cb.completion_tokens
usage_data["total_tokens"] = cb.total_tokens
usage_data["cost"] = cb.total_cost
- logger.info(f"{caller_name} usage: {usage_data}")
- _record_usage(caller_name, usage_data)
+ _record_usage(caller_name, usage_data, self.config)
return result
async def ainvoke_with_parser(
@@ -608,8 +652,7 @@ async def ainvoke_with_parser(
usage_data["output_tokens"] = cb.completion_tokens
usage_data["total_tokens"] = cb.total_tokens
usage_data["cost"] = cb.total_cost
- logger.info(f"{caller_name} usage: {usage_data}")
- _record_usage(caller_name, usage_data)
+ _record_usage(caller_name, usage_data, self.config)
raw_text = self._message_text(raw_output)
diff --git a/common/llm_services/capabilities.py b/common/llm_services/capabilities.py
index 653c3fd9..0d5f3266 100644
--- a/common/llm_services/capabilities.py
+++ b/common/llm_services/capabilities.py
@@ -26,6 +26,7 @@
"""
import logging
+import threading
logger = logging.getLogger(__name__)
@@ -109,7 +110,17 @@ def openai_rejects_temperature(model: str) -> bool:
def _gemini_tool_calling(model: str) -> bool:
# Gemini 1.5+ and 2.x support function calling.
- return "gemini-1.5" in model or "gemini-2" in model or "gemini-exp" in model
+ # Every Gemini family from 1.5 onward supports function/tool calling, and
+ # future families (4.x, 5.x, ...) will too. Use a denylist instead of an
+ # allowlist so new models work without a code change: any Gemini is capable
+ # except the legacy 1.0-era models that predate function calling.
+ if "gemini" not in model:
+ return False
+ if "gemini-1.0" in model or "gemini-pro-vision" in model:
+ return False
+ if model.strip() == "gemini-pro": # bare 1.0 alias (versioned ids are fine)
+ return False
+ return True
def _gemini_thinking(model: str) -> bool:
@@ -162,3 +173,164 @@ def model_supports_agentic(config: dict) -> bool:
(config or {}).get("llm_model"),
)
return caps["supports_tool_calling"]
+
+
+# ---------------------------------------------------------------------------
+# Runtime tool-calling probe (GML-2169)
+#
+# Policy: Agentic mode is ON by default. We only DISABLE it when we are sure
+# the model can't tool-call — a known-legacy model, an explicit "tools not
+# supported" error from the probe, or a real tool-calling failure at query
+# time (mark_tool_calling_unsupported). Anything uncertain (transient error,
+# ambiguous failure, timeout, no provider yet) stays ENABLED and is not cached,
+# so a flaky probe never disables a capable model. Results are cached in memory
+# (per process), keyed by service:model — never persisted to config, re-probed
+# after a restart, and sticky until the model config changes (new key).
+# ---------------------------------------------------------------------------
+
+_probe_cache: dict = {}
+_probe_lock = threading.Lock()
+
+_PROBE_TIMEOUT_S = 20
+
+# Error text that positively indicates the model rejects tool/function calling.
+_NO_TOOL_SUPPORT_MARKERS = (
+ "does not support tool",
+ "not support tools",
+ "tools are not supported",
+ "tool use is not supported",
+ "tool calling is not supported",
+ "function calling is not supported",
+ "does not support function",
+ "tool_choice is not supported",
+ "tools is not supported",
+)
+
+
+def _probe_key(config: dict) -> str:
+ service = (config.get("llm_service") or "").strip().lower()
+ model = (config.get("llm_model") or "").strip().lower()
+ return f"{service}:{model}"
+
+
+def _known_no_tool_calling(config: dict) -> bool:
+ """True only for models we are *sure* predate tool/function calling."""
+ service = (config.get("llm_service") or "").strip().lower()
+ model = _strip_region((config.get("llm_model") or "").strip().lower())
+ if "gemini-1.0" in model or "gemini-pro-vision" in model or model == "gemini-pro":
+ return True
+ if service in ("openai", "azure", "azure_openai", "azureopenai"):
+ if model.startswith(("text-davinci", "davinci", "curie", "babbage", "ada",
+ "text-ada", "text-babbage", "text-curie")):
+ return True
+ if service in ("bedrock", "aws_bedrock", "awsbedrock"):
+ if ("amazon.titan" in model
+ or "meta.llama2" in model
+ or "ai21.j2" in model
+ or "ai21.jurassic" in model
+ or "anthropic.claude-instant" in model
+ or "anthropic.claude-v2" in model
+ or model.startswith("anthropic.claude-2")
+ or ("cohere.command" in model and "command-r" not in model)):
+ return True
+ return False
+
+
+def _looks_like_no_tool_support(exc) -> bool:
+ msg = str(exc).lower()
+ return any(m in msg for m in _NO_TOOL_SUPPORT_MARKERS)
+
+
+def _invoke_probe(llm):
+ from pydantic import BaseModel, Field
+
+ class _ProbePing(BaseModel):
+ """Acknowledge readiness by calling this tool."""
+ ok: bool = Field(default=True, description="always true")
+
+ bound = llm.bind_tools([_ProbePing])
+ bound.invoke([
+ ("system", "You can call tools."),
+ ("user", "Call the ProbePing tool with ok set to true."),
+ ])
+
+
+def _run_tool_calling_probe(llm_provider):
+ """Bind a trivial tool and make a minimal, time-bounded call.
+
+ Returns ``True`` (confirmed tool-calling), ``False`` (the model explicitly
+ rejects tools — a *confident* no-support signal), or ``None`` (unknown:
+ transient, ambiguous, timeout, or no usable provider). ``None`` must not
+ disable Agentic mode.
+ """
+ import concurrent.futures
+
+ llm = getattr(llm_provider, "llm", None)
+ if llm is None or not hasattr(llm, "bind_tools"):
+ return None
+ try:
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
+ ex.submit(_invoke_probe, llm).result(timeout=_PROBE_TIMEOUT_S)
+ return True
+ except concurrent.futures.TimeoutError:
+ logger.warning("tool-calling probe timed out; leaving Agentic enabled")
+ return None
+ except Exception as exc: # noqa: BLE001
+ if _looks_like_no_tool_support(exc):
+ logger.info("tool-calling probe: model rejects tools (%s)", str(exc)[:200])
+ return False
+ logger.warning(
+ "tool-calling probe inconclusive (%s); leaving Agentic enabled",
+ str(exc)[:200],
+ )
+ return None
+
+
+def supports_tool_calling(config: dict, llm_provider=None) -> bool:
+ """Whether the resolved chat model can drive Agentic mode.
+
+ Optimistic: enabled unless we are sure it can't tool-call. Uses a cached
+ in-memory runtime probe; a cached result wins, a known-legacy model is
+ disabled, a confident probe/runtime failure disables, and everything else
+ stays enabled. Never writes to config.
+ """
+ if not isinstance(config, dict):
+ return False
+ key = _probe_key(config)
+ with _probe_lock:
+ if key in _probe_cache:
+ return _probe_cache[key]
+
+ if _known_no_tool_calling(config):
+ with _probe_lock:
+ _probe_cache[key] = False
+ return False
+
+ if llm_provider is None:
+ return True # optimistic; can't probe yet, don't cache
+
+ result = _run_tool_calling_probe(llm_provider)
+ if result is True:
+ with _probe_lock:
+ _probe_cache[key] = True
+ return True
+ if result is False: # confident no-support
+ with _probe_lock:
+ _probe_cache[key] = False
+ return False
+ return True # unknown -> stay enabled, don't cache
+
+
+def mark_tool_calling_unsupported(config: dict) -> None:
+ """Record that the chat model failed tool-calling at runtime, so later
+ requests downgrade to the classic engine until the model config changes or
+ the container restarts."""
+ if isinstance(config, dict):
+ with _probe_lock:
+ _probe_cache[_probe_key(config)] = False
+
+
+def reset_tool_calling_cache() -> None:
+ """Clear the in-memory probe cache (test hook / manual reset)."""
+ with _probe_lock:
+ _probe_cache.clear()
diff --git a/common/utils/image_data_extractor.py b/common/utils/image_data_extractor.py
index 6be929cc..51721f79 100644
--- a/common/utils/image_data_extractor.py
+++ b/common/utils/image_data_extractor.py
@@ -119,7 +119,14 @@ def describe_image_with_llm(file_path):
"stacked bar with a time-period axis), TRANSCRIBE every "
"(period, value) pair you can read in the format "
"`period: value; period: value; …` — do not summarize "
- "the trend in place of the values; (3) the entities, "
+ "the trend in place of the values. For categorical "
+ "bar/rank charts (categories such as regions, "
+ "companies, or age groups on one axis and a numeric "
+ "unit on the other), TRANSCRIBE every visible "
+ "category with its value as "
+ "`category: value; category: value; …` including the "
+ "unit — never list categories without their numbers; "
+ "(3) the entities, "
"relationships, or process steps in any diagram or "
"flowchart; (4) any logo or branding mark, identified by "
"name. Do NOT describe layout, background color, "
diff --git a/common/utils/summary_placeholders.py b/common/utils/summary_placeholders.py
new file mode 100644
index 00000000..74cd0036
--- /dev/null
+++ b/common/utils/summary_placeholders.py
@@ -0,0 +1,25 @@
+"""Community-summary placeholder markers, shared by ECC (writes them) and the
+graphrag app / Migration Assistant (detects them). Single source of truth so the
+health check and the rebuild pipeline agree on what "needs re-summarization"
+means.
+"""
+
+# Written as a community's description when summarization can't produce a real
+# one. Non-empty so the layer-completion check passes and the rebuild finishes,
+# and stable so it can be found and regenerated later.
+COMMUNITY_SUMMARY_PLACEHOLDER = "[summary unavailable - regenerate]"
+
+# Placeholder written by pre-2.0.1 builds; kept so re-summarization and progress
+# checks recognize communities left behind by older graphs too.
+LEGACY_SUMMARY_PLACEHOLDER = "Should ignore due to summary error."
+
+# Non-empty markers a description may carry. An empty description is also treated
+# as needing re-summarization, but "" is handled separately (GSQL length check).
+PLACEHOLDER_MARKERS = [COMMUNITY_SUMMARY_PLACEHOLDER, LEGACY_SUMMARY_PLACEHOLDER]
+
+
+def is_placeholder_summary(text: str) -> bool:
+ """True if a community description is a placeholder needing regeneration:
+ the current or legacy sentinel, or empty."""
+ t = (text or "").strip()
+ return t in ("", COMMUNITY_SUMMARY_PLACEHOLDER, LEGACY_SUMMARY_PLACEHOLDER)
diff --git a/common/utils/text_extractors.py b/common/utils/text_extractors.py
index 09399a92..41a441e8 100644
--- a/common/utils/text_extractors.py
+++ b/common/utils/text_extractors.py
@@ -41,8 +41,11 @@
# which bloat tokens 3-5x and confuse retrieval embeddings. The CJK
# Unicode ranges below cover CJK Unified Ideographs (U+4E00-U+9FFF),
# Hiragana / Katakana / CJK Symbols (U+3000-U+30FF), and full-width
-# / half-width forms (U+FF00-U+FFEF).
-_CJK_CHAR_CLASS = r"[ -鿿-]"
+# / half-width forms (U+FF00-U+FFEF) excluding fullwidth digits
+# (U+FF10-U+FF19). Collapsing digit runs would glue distinct chart
+# values such as 767 and 808 into ``767808``.
+# One CJK char excluding fullwidth digits (U+FF10-U+FF19).
+_CJK_CHAR_CLASS = r"(?:[ -鿿]|[-/]|[:-])"
_VERTICAL_BOLD_CJK = re.compile(
rf"(?:\*\*{_CJK_CHAR_CLASS}\*\*(?: )){{2,}}\*\*{_CJK_CHAR_CLASS}\*\*"
)
@@ -60,6 +63,20 @@
_TABLE_LINE_RE = re.compile(r"^\s*\|")
_BR_TAG_RE = re.compile(r" ", re.IGNORECASE)
+# pymupdf4llm picture-text blocks (HTML-comment or markdown-dash markers).
+# These are figure-associated text from pymupdf4llm, not a separate OCR engine.
+_PICTURE_TEXT_BLOCK_RE = re.compile(
+ r"(?:|\*{0,3}\s*-+\s*Start of picture text\s*-+\s*\*{0,3})"
+ r"(.*?)"
+ r"(?:|\*{0,3}\s*-+\s*End of picture text\s*-+\s*\*{0,3})",
+ re.IGNORECASE | re.DOTALL,
+)
+
+# Adjacent comma-grouped numbers glued with no separator, e.g. ``1,5461,518``.
+_GLUED_COMMA_NUMBERS_RE = re.compile(
+ r"(? list[dict]:
def _strip_br_in_table_rows(text: str) -> str:
- """Remove `` `` tags inside markdown table rows.
+ """Replace `` `` tags inside markdown table rows with spaces.
- Rationale documented at _TABLE_LINE_RE.
+ Using a space (not empty string) keeps stacked chart values distinct
+ — ``|767 808|`` becomes ``|767 808|``, never ``|767808|``.
"""
out: list[str] = []
for line in text.split("\n"):
if _TABLE_LINE_RE.match(line):
line = _BR_TAG_RE.sub(" ", line)
+ # Collapse runs of whitespace left by consecutive tags.
+ line = re.sub(r"[ \t]{2,}", " ", line)
out.append(line)
return "\n".join(out)
+def _split_glued_comma_numbers(text: str) -> str:
+ """Insert a space between adjacent comma-grouped numbers.
+
+ pymupdf4llm / chart extraction sometimes emits ``1,5461,518`` instead of
+ ``1,546 1,518``. Repeat until stable for longer glued runs.
+ """
+ prev = None
+ while prev != text:
+ prev = text
+ text = _GLUED_COMMA_NUMBERS_RE.sub(r"\1 \2", text)
+ return text
+
+
+def _normalize_picture_text_blocks(text: str) -> str:
+ """Normalize pymupdf4llm picture-text blocks for chunking + retrieval.
+
+ - Rewrite HTML-comment markers to the markdown form StructuredChunker
+ already recognizes.
+ - Turn in-block `` `` into newlines (not empty joins) so values like
+ ``767`` and ``808`` stay separable.
+ - Split glued comma-numbers inside the block.
+ """
+
+ def _rewrite(match: re.Match) -> str:
+ body = match.group(1) or ""
+ body = _BR_TAG_RE.sub("\n", body)
+ body = _split_glued_comma_numbers(body)
+ # Trim excess blank lines inside the block.
+ body = re.sub(r"\n{3,}", "\n\n", body).strip("\n")
+ return (
+ "***----- Start of picture text -----***\n"
+ f"{body}\n"
+ "***----- End of picture text -----***"
+ )
+
+ return _PICTURE_TEXT_BLOCK_RE.sub(_rewrite, text)
+
+
+_PAGE_MARKER_RE = re.compile(r"")
+
+
+def _recover_mojibake_pages(
+ file_path,
+ markdown: str,
+ graphname=None,
+ max_pages: int = 3,
+) -> str:
+ """Recover table/chart text from PDF pages with broken ToUnicode CMaps.
+
+ When glyph mapping fails, embedded text often keeps numbers but corrupts
+ labels. Drawn (non-embedded) figures also skip the normal image-describe
+ pass. For page sections that look both corrupted and table-like, render
+ the page and multimodal-transcribe it, then append the result next to the
+ original page body. Capped by ``max_pages`` to bound cost.
+ """
+ if not markdown or max_pages <= 0:
+ return markdown
+
+ # Collect page numbers whose section text looks corrupted.
+ parts = _PAGE_MARKER_RE.split(markdown)
+ # parts: [pre, pageNo, body, pageNo, body, ...]
+ bad_pages: list[int] = []
+ if len(parts) >= 3:
+ for i in range(1, len(parts), 2):
+ try:
+ page_no = int(parts[i])
+ except (TypeError, ValueError):
+ continue
+ body = parts[i + 1] if i + 1 < len(parts) else ""
+ findings = _detect_mojibake(body, source_hint=f"{file_path}:p{page_no}")
+ # Prefer pages that look like broken tables (pipe rows + mojibake).
+ pipe_rows = sum(1 for ln in body.splitlines() if ln.strip().startswith("|"))
+ if len(findings) >= 3 and pipe_rows >= 3:
+ bad_pages.append(page_no)
+ if not bad_pages:
+ return markdown
+
+ try:
+ import pymupdf
+ from common.utils.image_data_extractor import (
+ describe_image_with_llm,
+ should_extract_images,
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("mojibake page recovery unavailable: %s", e)
+ return markdown
+
+ if not should_extract_images(graphname):
+ return markdown
+
+ recovered: dict[int, str] = {}
+ try:
+ doc = pymupdf.open(str(file_path))
+ except Exception as e: # noqa: BLE001
+ logger.warning("mojibake recovery: cannot open %s: %s", file_path, e)
+ return markdown
+
+ try:
+ for page_no in bad_pages[:max_pages]:
+ idx = page_no - 1
+ if idx < 0 or idx >= doc.page_count:
+ continue
+ try:
+ page = doc[idx]
+ # ~150 dpi — enough to read table cells without huge payloads.
+ pix = page.get_pixmap(matrix=pymupdf.Matrix(2.0, 2.0), alpha=False)
+ tmp = Path(tempfile.mkdtemp(prefix="mojibake_page_")) / f"p{page_no}.png"
+ pix.save(str(tmp))
+ desc = describe_image_with_llm(str(tmp))
+ try:
+ shutil.rmtree(tmp.parent, ignore_errors=True)
+ except Exception:
+ pass
+ if not desc or "decorative image" in desc.lower():
+ continue
+ # Skip if the transcription itself looks glyph-broken.
+ if len(_detect_mojibake(desc)) >= 3:
+ continue
+ recovered[page_no] = desc.strip()
+ logger.info(
+ "mojibake page recovery: %s page %s recovered %s chars",
+ file_path,
+ page_no,
+ len(desc),
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning(
+ "mojibake page recovery failed for %s p%s: %s",
+ file_path,
+ page_no,
+ e,
+ )
+ finally:
+ doc.close()
+
+ if not recovered:
+ return markdown
+
+ # Append recovered transcription under each page marker body.
+ out_parts: list[str] = [parts[0]]
+ for i in range(1, len(parts), 2):
+ page_no_s = parts[i]
+ body = parts[i + 1] if i + 1 < len(parts) else ""
+ out_parts.append(f"")
+ out_parts.append(body)
+ try:
+ page_no = int(page_no_s)
+ except (TypeError, ValueError):
+ continue
+ if page_no in recovered:
+ out_parts.append(
+ "\n\n\n"
+ + recovered[page_no]
+ + "\n"
+ )
+ return "\n".join(out_parts)
+
+
def _collapse_vertical_cjk(text: str) -> str:
"""Collapse pymupdf4llm's per-character vertical-CJK runs back into a
single token. Bold runs ``**X** **Y** **Z**`` become ``**XYZ**``;
@@ -158,11 +336,21 @@ def _clean_pdf_markdown(markdown: str, source_hint: str = "") -> str:
with `` `` separators and per-character bold markers. The run is
collapsed back into a single token so embedding and retrieval see the
intended word (e.g. ``**個別信用購入あっせん**``) rather than ten
- fragments.
+ fragments. Fullwidth digits are excluded so chart values are not glued.
+
+ 4. **Picture-text blocks** — figure text wrapped in
+ ```` (or the dash-marker form) by
+ pymupdf4llm is rewritten so StructuredChunker keeps the block atomic,
+ `` `` becomes newlines, and glued comma-numbers like ``1,5461,518``
+ are split.
"""
# --- Pass 1: remove ColN placeholders ---
markdown = _coln_pattern.sub('', markdown)
+ # --- Pass 1b: normalize pymupdf4llm picture-text blocks before CJK/table
+ # passes so chart stacks become newlines rather than empty joins.
+ markdown = _normalize_picture_text_blocks(markdown)
+
# --- Pass 2: collapse vertical-CJK runs (do this BEFORE row dedup so
# rows that differ only by the collapsed form aren't treated as
# distinct rows).
@@ -171,7 +359,10 @@ def _clean_pdf_markdown(markdown: str, source_hint: str = "") -> str:
# --- Pass 2b: strip inside markdown table rows ---
markdown = _strip_br_in_table_rows(markdown)
- # --- Pass 2c: log lines that look like mojibake (failed glyph decode).
+ # --- Pass 2c: split glued comma-grouped numbers globally ---
+ markdown = _split_glued_comma_numbers(markdown)
+
+ # --- Pass 2d: log lines that look like mojibake (failed glyph decode).
# We don't repair these — the underlying glyphs aren't recoverable
# from the markdown — but logging gives operators a grep target.
findings = _detect_mojibake(markdown, source_hint)
@@ -697,6 +888,12 @@ def _to_markdown_paged(strategy: str | None = None):
# Clean up artefacts common in form PDFs (duplicate rows, ColN headers)
markdown_content = _clean_pdf_markdown(markdown_content, source_hint=str(file_path))
+ # Pages with broken CMaps (mojibake row labels, intact numbers) need a
+ # page-screenshot multimodal pass — embedded images are often absent.
+ markdown_content = _recover_mojibake_pages(
+ file_path, markdown_content, graphname=graphname
+ )
+
# Rename image files that contain spaces to avoid path-parsing issues
markdown_content = _sanitize_image_filenames(image_output_folder, markdown_content)
diff --git a/ecc/app/graphrag/regenerate.py b/ecc/app/graphrag/regenerate.py
new file mode 100644
index 00000000..45f760e5
--- /dev/null
+++ b/ecc/app/graphrag/regenerate.py
@@ -0,0 +1,205 @@
+"""Targeted regeneration actions for the Migration Assistant data-integrity
+panel:
+
+- ``regenerate_embeddings`` — re-embed vertices missing an embedding (GML-2175).
+- ``regenerate_summaries`` — re-summarize communities whose description is a
+ placeholder or empty, then re-embed the new summary (GML-2176).
+
+Both reuse the rebuild pipeline's building blocks (``get_commuinty_children``,
+``CommunitySummarizer``, the embedding store) — no new embedding/summarization
+logic. Vertices whose source text is empty/placeholder are skipped and counted;
+they need a rebuild/re-ingest, not a re-embed.
+"""
+
+import logging
+
+from common.config import (
+ get_llm_service,
+ get_completion_config,
+ get_embedding_service,
+)
+from common.embeddings.tigergraph_embedding_store import TigerGraphEmbeddingStore
+from common.utils.summary_placeholders import PLACEHOLDER_MARKERS, is_placeholder_summary
+from graphrag import util, community_summarizer
+
+logger = logging.getLogger(__name__)
+
+_VECTOR_ATTR = "embedding"
+
+
+def _make_store(conn, graphname):
+ store = TigerGraphEmbeddingStore(
+ conn, get_embedding_service(), support_ai_instance=True
+ )
+ store.set_graphname(graphname)
+ return store
+
+
+async def _embeddable_types(conn) -> list[str]:
+ """Vertex types carrying the embedding attribute (async connection)."""
+ # Native vector attributes are absent from getVertexAttrs; they live under
+ # each vertex type's ``EmbeddingAttributes`` in the schema.
+ try:
+ schema = await conn.getSchema()
+ except Exception as e:
+ logger.warning(f"regen: getSchema failed: {e}")
+ return []
+ out = []
+ for v in schema.get("VertexTypes", []):
+ if any(e.get("Name") == _VECTOR_ATTR for e in (v.get("EmbeddingAttributes") or [])):
+ out.append(v.get("Name"))
+ return out
+
+
+def _row_id(r):
+ """Vertex id from a printed vertex row, tolerant of the two shapes
+ pyTigerGraph returns (``v_id`` vs an aliased ``id`` attribute)."""
+ return r.get("v_id") or r.get("attributes", {}).get("id") or r.get("id")
+
+
+async def _chunk_text(conn, chunk_id):
+ try:
+ res = await conn.runInstalledQuery(
+ "StreamChunkContent", params={"chunk": (chunk_id,)}
+ )
+ rows = res[0].get("ChunkContent") if res else None
+ if rows:
+ return rows[0].get("attributes", {}).get("text", "") or ""
+ except Exception as e:
+ logger.warning(f"regen: chunk text fetch failed for {chunk_id}: {e}")
+ return ""
+
+
+async def _description(conn, vtype, vid):
+ try:
+ v = await conn.getVerticesById(vtype, vid)
+ if v:
+ desc = v[0].get("attributes", {}).get("description", "")
+ # Entity descriptions can be a list; join for embedding.
+ if isinstance(desc, list):
+ desc = " ".join(str(x) for x in desc if x)
+ return desc or ""
+ except Exception as e:
+ logger.warning(f"regen: description fetch failed for {vtype} {vid}: {e}")
+ return ""
+
+
+async def regenerate_embeddings(graphname, conn):
+ """Re-embed vertices missing an embedding, per embeddable type. Returns
+ ``{"regenerated": n, "skipped": m}``; skipped = empty/placeholder source
+ (needs a rebuild/re-summarize) or an embed error."""
+ util.loading_event.set()
+ store = _make_store(conn, graphname)
+ regenerated = 0
+ skipped = 0
+ for vt in await _embeddable_types(conn):
+ try:
+ res = await conn.runInstalledQuery(
+ "vertices_have_embedding",
+ params={"vertex_type": vt, "verbose": True},
+ )
+ results = next(
+ (r["results"] for r in res if isinstance(r, dict) and "results" in r),
+ [],
+ )
+ ids = [i for i in (_row_id(r) for r in results) if i]
+ except Exception as e:
+ logger.warning(f"regen_embeddings: list missing for {vt} failed: {e}")
+ continue
+ for vid in ids:
+ text = (
+ await _chunk_text(conn, vid)
+ if vt == "DocumentChunk"
+ else await _description(conn, vt, vid)
+ )
+ if not text or is_placeholder_summary(text):
+ skipped += 1
+ continue
+ try:
+ await store.aadd_embeddings([(text, [])], [{"vertex_id": (vid, vt)}])
+ regenerated += 1
+ except Exception as e:
+ logger.warning(f"regen_embeddings: re-embed failed {vt} {vid}: {e}")
+ skipped += 1
+ logger.info(
+ f"regenerate_embeddings({graphname}): "
+ f"regenerated={regenerated} skipped={skipped}"
+ )
+ return {"regenerated": regenerated, "skipped": skipped}
+
+
+async def regenerate_summaries(graphname, conn):
+ """Re-summarize communities with placeholder/empty descriptions, then
+ re-embed. Returns ``{"resummarized": n, "skipped": m}``; skipped = no usable
+ child text or a summarization failure (needs a rebuild/re-ingest)."""
+ util.loading_event.set()
+ store = _make_store(conn, graphname)
+ llm = get_llm_service(get_completion_config(graphname))
+ summarizer = community_summarizer.CommunitySummarizer(llm)
+ resummarized = 0
+ skipped = 0
+ try:
+ res = await conn.runInstalledQuery(
+ "communities_need_resummarize",
+ params={"markers": PLACEHOLDER_MARKERS, "p": True},
+ )
+ targets = next(
+ (r["results"] for r in res if isinstance(r, dict) and "results" in r), []
+ )
+ except Exception as e:
+ logger.error(f"regen_summaries: target list failed: {e}")
+ return {"resummarized": 0, "skipped": 0, "error": str(e)}
+
+ for t in targets:
+ cid = _row_id(t)
+ try:
+ i = int(t.get("attributes", {}).get("iteration", 0))
+ except (TypeError, ValueError):
+ i = 0
+ if not cid:
+ skipped += 1
+ continue
+ try:
+ children = await util.get_commuinty_children(conn, i, cid)
+ except Exception as e:
+ logger.warning(f"regen_summaries: children fetch failed {cid}: {e}")
+ skipped += 1
+ continue
+ if not children:
+ skipped += 1
+ continue
+ if len(children) == 1:
+ summary = children[0]
+ else:
+ r = await summarizer.summarize(cid, children)
+ if r.get("error"):
+ logger.warning(
+ f"regen_summaries: summarize failed {cid}: {r.get('message')}"
+ )
+ skipped += 1
+ continue
+ summary = r["summary"]
+ if not summary or is_placeholder_summary(summary):
+ skipped += 1
+ continue
+ pid = util.process_id(cid)
+ try:
+ # Direct upsert (not util.upsert_vertex, which only enqueues to the
+ # rebuild loader queue that isn't running in a standalone regenerate
+ # action — the description write would be silently dropped).
+ await conn.upsertVertex(
+ "Community", pid,
+ attributes={"description": summary, "iteration": i},
+ )
+ await store.aadd_embeddings(
+ [(summary, [])], [{"vertex_id": (pid, "Community")}]
+ )
+ resummarized += 1
+ except Exception as e:
+ logger.warning(f"regen_summaries: upsert/embed failed {cid}: {e}")
+ skipped += 1
+ logger.info(
+ f"regenerate_summaries({graphname}): "
+ f"resummarized={resummarized} skipped={skipped}"
+ )
+ return {"resummarized": resummarized, "skipped": skipped}
diff --git a/ecc/app/graphrag/util.py b/ecc/app/graphrag/util.py
index 60129e8e..37313b75 100644
--- a/ecc/app/graphrag/util.py
+++ b/ecc/app/graphrag/util.py
@@ -65,19 +65,15 @@
loading_event = asyncio.Event()
loading_event.set() # set the event to true to allow the workers to run
-# Written as a community's description when summarization can't produce a real
-# one. Non-empty so the layer-completion check passes and the rebuild finishes,
-# and stable so it can be found and regenerated later.
-COMMUNITY_SUMMARY_PLACEHOLDER = "[summary unavailable - regenerate]"
-# Placeholder written by pre-2.0.1 builds; kept so re-summarization and progress
-# checks recognize communities left behind by older graphs too.
-LEGACY_SUMMARY_PLACEHOLDER = "Should ignore due to summary error."
-
-def is_placeholder_summary(text: str) -> bool:
- """True if a community description is a placeholder needing regeneration:
- the current or legacy sentinel, or empty."""
- t = (text or "").strip()
- return t in ("", COMMUNITY_SUMMARY_PLACEHOLDER, LEGACY_SUMMARY_PLACEHOLDER)
+# Community-summary placeholder markers now live in a shared module so the
+# graphrag app / Migration Assistant detect exactly what ECC writes. Re-exported
+# here for the existing ECC callers.
+from common.utils.summary_placeholders import ( # noqa: E402,F401
+ COMMUNITY_SUMMARY_PLACEHOLDER,
+ LEGACY_SUMMARY_PLACEHOLDER,
+ PLACEHOLDER_MARKERS,
+ is_placeholder_summary,
+)
async def install_queries(
requried_queries: list[str],
@@ -220,9 +216,12 @@ def map_attrs(attributes: dict):
def process_id(v_id: str):
- has_func = re.compile(r"(.*)\(").findall(v_id)
- if len(has_func) > 0:
- v_id = has_func[0]
+ # Strip parentheses in place — do NOT truncate at "(". The old `(.*)\(`
+ # truncation dropped everything from the first "(" onward, which mangled
+ # ids for document names containing parentheses (e.g. "Q3 (2026) report"):
+ # the "_chunk_{i}" suffix was lost, so chunk ids no longer ended in an
+ # integer and the rebuild crashed on int(chunk_id.split("_")[-1]). The
+ # replace() below removes the parens without dropping the rest. (GML-2173)
v_id = v_id.replace(" ", "_").lower().replace("/", "_").replace("(", "").replace(")", "")
if v_id == "''" or v_id == '""':
return ""
diff --git a/ecc/app/graphrag/workers.py b/ecc/app/graphrag/workers.py
index 1dc7d1b9..518a2d82 100644
--- a/ecc/app/graphrag/workers.py
+++ b/ecc/app/graphrag/workers.py
@@ -173,7 +173,7 @@ async def chunk_doc(
# send chunks to be upserted (func, args)
logger.debug("chunk writes to upsert_chan")
- await upsert_chan.put((upsert_chunk, (conn, v_id, chunk_id, chunk)))
+ await upsert_chan.put((upsert_chunk, (conn, v_id, chunk_id, chunk, i)))
# send chunks to have entities extracted
logger.debug("chunk writes to extract_chan")
@@ -208,7 +208,7 @@ async def upsert_doc(conn: AsyncTigerGraphConnection, doc_id, ctype, content_tex
conn, "Document", doc_id, "HAS_CONTENT", "Content", doc_id
)
-async def upsert_chunk(conn: AsyncTigerGraphConnection, doc_id, chunk_id, chunk):
+async def upsert_chunk(conn: AsyncTigerGraphConnection, doc_id, chunk_id, chunk, idx):
logger.debug(f"Upserting chunk {chunk_id}")
date_added = int(time.time())
# Build the chunk's full vertex + edge bundle and enqueue atomically.
@@ -220,7 +220,7 @@ async def upsert_chunk(conn: AsyncTigerGraphConnection, doc_id, chunk_id, chunk)
("DocumentChunk", chunk_id, {
"epoch_added": date_added,
"epoch_processed": date_added,
- "idx": int(chunk_id.split("_")[-1]),
+ "idx": idx,
}),
("Content", chunk_id, {"text": chunk, "epoch_added": date_added}),
]
@@ -228,7 +228,6 @@ async def upsert_chunk(conn: AsyncTigerGraphConnection, doc_id, chunk_id, chunk)
("DocumentChunk", chunk_id, "HAS_CONTENT", "Content", chunk_id, None),
("Document", doc_id, "HAS_CHILD", "DocumentChunk", chunk_id, None),
]
- idx = int(chunk_id.split("_")[-1])
if idx > 0:
edges.append((
"DocumentChunk", chunk_id, "IS_AFTER",
diff --git a/ecc/app/main.py b/ecc/app/main.py
index 5bcb8364..9624cb36 100644
--- a/ecc/app/main.py
+++ b/ecc/app/main.py
@@ -435,3 +435,63 @@ def consistency_update(
return f"Method unsupported, must be {SupportAIMethod.SUPPORTAI}, {SupportAIMethod.GRAPHRAG}"
return {"status": "submitted", "message": ecc_status}
+
+
+def _regen_build_conn(graphname, credentials):
+ """Async DB connection for a regenerate action (mirrors consistency_update
+ auth handling)."""
+ reload_db_config()
+ if isinstance(credentials, HTTPBasicCredentials):
+ conn = elevate_db_connection_to_token(
+ db_config.get("hostname"), credentials.username, credentials.password,
+ graphname, async_conn=True,
+ )
+ elif isinstance(credentials, HTTPAuthorizationCredentials):
+ conn = get_db_connection_id_token(
+ graphname, credentials.credentials, async_conn=True
+ )
+ else:
+ raise HTTPException(status_code=401, detail="Invalid authentication credentials")
+ asyncio.run(conn.customizeHeader(
+ timeout=db_config.get("default_timeout", 300) * 1000, responseSize=5000000
+ ))
+ return conn
+
+
+def _run_regen(graphname, credentials, task_suffix, run_func):
+ """Run a targeted regenerate action synchronously, returning its counts.
+ Refuses while a rebuild or the same action is already in flight (they both
+ write embeddings)."""
+ rebuild_key = f"{graphname}:graphrag"
+ if rebuild_key in running_tasks and running_tasks[rebuild_key].get("status") == "running":
+ raise HTTPException(
+ status_code=409,
+ detail=f"A rebuild is in progress for {graphname}; retry after it completes.",
+ )
+ task_key = f"{graphname}:{task_suffix}"
+ if task_key in running_tasks and running_tasks[task_key].get("status") == "running":
+ raise HTTPException(status_code=409, detail=f"{task_suffix} already running for {graphname}")
+ conn = _regen_build_conn(graphname, credentials)
+ running_tasks[task_key] = {"status": "running", "started_at": time.time()}
+ try:
+ result = asyncio.run(run_func(graphname, conn))
+ LogWriter.info(f"Completed ECC task: {task_key} -> {result}")
+ return {"status": "completed", **result}
+ finally:
+ running_tasks.pop(task_key, None)
+
+
+@app.get("/{graphname}/graphrag/regenerate_embeddings")
+def regenerate_embeddings_endpoint(graphname: str, credentials=Depends(auth_credentials)):
+ """Re-embed vertices missing an embedding (GML-2175). Targeted, not a full
+ rebuild. Runs synchronously and returns {regenerated, skipped}."""
+ from graphrag.regenerate import regenerate_embeddings
+ return _run_regen(graphname, credentials, "regenerate_embeddings", regenerate_embeddings)
+
+
+@app.get("/{graphname}/graphrag/regenerate_summaries")
+def regenerate_summaries_endpoint(graphname: str, credentials=Depends(auth_credentials)):
+ """Re-summarize communities with placeholder/empty descriptions and re-embed
+ (GML-2176). Targeted, not a full rebuild. Returns {resummarized, skipped}."""
+ from graphrag.regenerate import regenerate_summaries
+ return _run_regen(graphname, credentials, "regenerate_summaries", regenerate_summaries)
diff --git a/ecc/app/supportai/util.py b/ecc/app/supportai/util.py
index 0d62c669..630bab93 100644
--- a/ecc/app/supportai/util.py
+++ b/ecc/app/supportai/util.py
@@ -2,7 +2,6 @@
import base64
import json
import logging
-import re
import traceback
from glob import glob
from typing import Callable
@@ -182,9 +181,10 @@ def map_attrs(attributes: dict):
def process_id(v_id: str):
- has_func = re.compile(r"(.*)\(").findall(v_id)
- if len(has_func) > 0:
- v_id = has_func[0]
+ # Strip parentheses in place — do NOT truncate at "(". Truncating dropped
+ # the "_chunk_{i}" suffix for ids containing parentheses, corrupting chunk
+ # ids. The replace() below removes the parens without dropping the rest.
+ # (GML-2173)
v_id = v_id.replace(" ", "_").lower().replace("/", "_").replace("(", "").replace(")", "")
if v_id == "''" or v_id == '""':
return ""
diff --git a/graphrag-ui/package-lock.json b/graphrag-ui/package-lock.json
index 7def4e45..267fbb24 100644
--- a/graphrag-ui/package-lock.json
+++ b/graphrag-ui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tg-cbot-v5",
- "version": "0.0.5",
+ "version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tg-cbot-v5",
- "version": "0.0.5",
+ "version": "2.0.2",
"dependencies": {
"@hookform/resolvers": "^3.6.0",
"@radix-ui/react-dialog": "^1.1.1",
diff --git a/graphrag-ui/package.json b/graphrag-ui/package.json
index c20d3dd8..936dba70 100755
--- a/graphrag-ui/package.json
+++ b/graphrag-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "tg-cbot-v5",
"private": true,
- "version": "0.0.5",
+ "version": "2.0.2",
"type": "module",
"packageManager": "pnpm@9.15.0",
"scripts": {
diff --git a/graphrag-ui/src/actions/MessageParser.tsx b/graphrag-ui/src/actions/MessageParser.tsx
index 9645b11d..a09f212e 100644
--- a/graphrag-ui/src/actions/MessageParser.tsx
+++ b/graphrag-ui/src/actions/MessageParser.tsx
@@ -7,6 +7,10 @@ interface MessageParserProps {
const MessageParser: React.FC = ({ children, actions }) => {
const parse = (message: string) => {
+ // Ignore empty / whitespace-only submits. Clicking the Stop button just as
+ // an answer finishes can leak a send after the input was cleared, which
+ // otherwise fires an empty query at the backend.
+ if (!message || !message.trim()) return;
actions.queryGraphragWs(message);
};
diff --git a/graphrag-ui/src/index.css b/graphrag-ui/src/index.css
index 22dc36e5..560ce602 100755
--- a/graphrag-ui/src/index.css
+++ b/graphrag-ui/src/index.css
@@ -78,6 +78,32 @@
}
}
+/* Streaming Stop control — must apply in BOTH light and dark themes. While a
+ response streams, ActionProvider toggles `chat-streaming` on document.body;
+ Bot.tsx hides the native paper-plane and portals a red Stop icon into the
+ send button. These previously lived inside `.dark` only, which left the
+ stop icon unsized in light mode (it fell back to the SVG's default size and
+ broke the layout). The `.react-chatbot-kit-chat-btn-send` prefix keeps the
+ fill from being overridden by the base `... svg { fill: #999 }` rule. */
+body.chat-streaming .react-chatbot-kit-chat-input {
+ pointer-events: none;
+ opacity: 0.5;
+}
+body.chat-streaming .react-chatbot-kit-chat-btn-send-icon {
+ display: none;
+}
+.react-chatbot-kit-chat-btn-send .graphrag-stop-icon {
+ display: block;
+ width: 15px;
+ height: 15px;
+ margin: 0 auto;
+ fill: #dc2626;
+ cursor: pointer;
+}
+.react-chatbot-kit-chat-btn-send:hover .graphrag-stop-icon {
+ fill: #b91c1c;
+}
+
.dark {
a {
@apply text-white;
@@ -88,32 +114,6 @@
.react-chatbot-kit-chat-input-container {
@apply !bg-background !border-[#3D3D3D];
}
- /* While a response streams, lock the text input. The Send button keeps its
- rounded cap (background) but its paper-plane icon is hidden and its click
- disabled; a red Stop icon is overlaid in the exact same spot (Bot.tsx
- portals it into the input-container). ActionProvider toggles
- ``chat-streaming`` on ``document.body`` at stream start / end. */
- body.chat-streaming .react-chatbot-kit-chat-input {
- pointer-events: none;
- opacity: 0.5;
- }
- /* Replace the send icon with a red Stop icon IN PLACE — same button, same
- position. Bot.tsx hides the native paper-plane and portals the stop icon
- into the send button; the button's click is intercepted to stop. */
- body.chat-streaming .react-chatbot-kit-chat-btn-send-icon {
- display: none;
- }
- .graphrag-stop-icon {
- display: block;
- width: 15px;
- height: 15px;
- margin: 0 auto;
- fill: #dc2626;
- cursor: pointer;
- }
- .react-chatbot-kit-chat-btn-send:hover .graphrag-stop-icon {
- fill: #b91c1c;
- }
.open-dg {
@apply bg-background;
}
diff --git a/graphrag-ui/src/pages/TraceLogs.tsx b/graphrag-ui/src/pages/TraceLogs.tsx
index a038655c..185b86fe 100644
--- a/graphrag-ui/src/pages/TraceLogs.tsx
+++ b/graphrag-ui/src/pages/TraceLogs.tsx
@@ -669,7 +669,7 @@ const TokenOverviewPanel: FC<{ trace: TraceData }> = ({ trace }) => {
- Cost is estimated based on the model's published per-token pricing. Actual billing may differ.
+ Cost uses rates from LLM Config when set; otherwise LangChain pricing. Actual billing may differ.
@@ -705,7 +705,7 @@ const TokenOverviewPanel: FC<{ trace: TraceData }> = ({ trace }) => {
- Cost is estimated based on the model's published per-token pricing. Actual billing may differ.
+ Cost uses rates from LLM Config when set; otherwise LangChain pricing. Actual billing may differ.
@@ -750,7 +750,7 @@ const TokenOverviewPanel: FC<{ trace: TraceData }> = ({ trace }) => {
{formatNumber(usage.total_tokens)}
Used for processing images and multimodal content
@@ -1324,6 +1502,13 @@ const LLMConfig = () => {
Model for entity extraction and community summarization.