diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd9daf6..dffa42ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [2.0.4] + +### Changed +- **Agentic answers reuse what the conversation and the graph schema already hold.** The agentic engines searched again for questions the conversation had already answered, and answered questions about the knowledge base itself, such as "what data do we have", from a document search. They now answer from the conversation or the graph schema when it already holds the answer, search only for the parts it does not, and search again when an earlier answer found nothing, was incomplete, or the question asks for the current or latest state. Trace Logs show this review in the plan step. +- **A question without an answer tells the user how to ask again.** When the retrieved information does not answer a question, or part of it, the answer says so and suggests asking again with more specific details. The fixed no-answer messages use the same wording. +- **Failed answers show their error in Trace Logs.** When GraphRAG cannot answer, the trace ends with a step that states the error. The chat message stays generic and asks the user to rephrase the question. +- **Trace Logs show the structured query that ran.** Each structured query step shows the query, any failed attempts with their errors, and the number of rows returned, also when the query returned no rows. +- **Routine requests log less.** Loading conversations, opening traces and similar requests write one line to the service log instead of four, and the web server's access log is off. `LOGLEVEL=DEBUG` restores the detailed lines. + +### Fixed +- **Regenerating embeddings no longer loses an embedding while reporting success.** With the OpenAI provider, a later Regenerate embeddings or Regenerate summaries run could fail to save an embedding and still count it as regenerated. The `openai` package is now held below version 3, and a vertex counts as regenerated only when its embedding is saved. +- **Agentic plans no longer come back without steps.** A model, often Gemini, could return a plan with no steps, so nothing was retrieved. Every plan now either retrieves or answers from what the conversation and schema already hold. +- **The agentic planner plans from the graph schema.** The planner did not know the graph's types, so it guessed from the question's wording whether a structured query would help. It now plans from the graph's schema and type descriptions, and picks up changes to them without a restart. +- **Structured queries no longer start with a step that cannot succeed.** Each structured query first ran a step that always failed before the query was generated. That step is now skipped, saving a model call per structured query. +- **The Trace Logs window keeps long content inside it.** A long line, such as a URL or a query, widened the window past the screen and hid the Close button. Long lines now wrap, and Download and Close stay visible. +- **Generated graph queries succeed more often.** Questions that filter by date or ask about missing values could fail on every attempt or return wrong rows, and some queries that TigerGraph rejects were retried until all attempts were used. Each generated query is now checked by TigerGraph before it runs (TigerGraph 4.0 and later), failed checks are corrected and tried again up to `cypher_max_attempts` times, dates and missing values follow TigerGraph's rules, and queries that would change data are rejected. +- **An empty structured query result no longer counts as data.** A query that returned no rows was treated as an answer, so the document search did not run. It now runs, in both the agentic and the classic engine. +- **External MCP servers load again.** New images installed MCP SDK 2, and the agent then skipped every server configured on the MCP Servers page. The SDK is now held below version 2. +- **The agentic planner and reactive agent see the most recent conversation turns.** They received the oldest turns, so a long earlier answer could hide the turns a follow-up refers to. They now receive the most recent turns. +- **Gemini 3 planning no longer loops at temperature 0.** Gemini 3 models could repeat text in the plan instead of finishing it. Agentic planning now uses temperature 1.0 for Gemini 3 when a lower value is configured, as Google recommends; answers, knowledge-graph building and other models keep the configured temperature. +- **Agentic planning no longer gets stuck writing its plan summary.** A model could repeat a phrase in the plan summary until it reached its output limit, which cost time and tokens and sometimes the plan. The summary is now kept short and the planner's output is capped. + +### Removed +- **The `tg_run_query` and `tg_get_neighbors` agent tools.** Structured retrieval writes and runs graph queries for the agent. Custom planner or agent prompts that name these tools should drop them. + +### Added +- **`cypher_max_attempts` setting.** How many times a structured graph query may be generated for one question (default `10`). +- **`planner_max_output_tokens` setting.** The most output tokens the planner may use for one plan (default `4000`; reasoning models use at least `16000`). + ## [2.0.3] ### Added diff --git a/README.md b/README.md index 1e70505d..73aabb24 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ --- ## Releases +* **10/15/2026**: GraphRAG v2.0.4 released. Agentic answers reuse what the conversation and the graph schema already hold and plan from the graph's schema, Trace Logs show the structured queries that ran and the error of a failed answer, external MCP servers load again, and fixed planning and structured-query issues. See [Release Notes](https://github.com/tigergraph/graphrag/releases/tag/v2.0.4) for details. * **10/12/2026**: GraphRAG v2.0.3 released. Improved agentic answers to multi-part and follow-up questions, kept answers within the chat model's input limit, added deleting chat conversations individually, in groups, or all at once, made the Migration Assistant check a graph's schema before repairing it, and fixed document retrieval, ingestion, and contextual search issues. See [Release Notes](https://github.com/tigergraph/graphrag/releases/tag/v2.0.3) for details. * **8/28/2026**: GraphRAG v2.0.2 released. Added Migration Assistant data-integrity checks with targeted re-embedding of missing embeddings and re-summarization of incomplete community summaries, made agentic chat work with current and future tool-calling models automatically, and fixed rebuild, PDF extraction, and chat issues. See [Release Notes](https://github.com/tigergraph/graphrag/releases/tag/v2.0.2) for details. * **7/22/2026**: GraphRAG v2.0.1 released. Fixed a chat error with models that return structured content (such as Gemini 3), and hardened knowledge-graph rebuild so it no longer hangs when the language model is unreachable. Relicensed from Apache 2.0 to AGPL-3.0. See [Release Notes](https://github.com/tigergraph/graphrag/releases/tag/v2.0.1) for details. @@ -198,8 +199,8 @@ This line can be changed to support different logging levels. | `CRITICAL` | A serious error. | | `ERROR` | Failing to perform functions. | | `WARNING` | Indication of unexpected problems, e.g. failure to map a user’s question to the graph schema. | -| `INFO` | Confirming that the service is performing as expected. | -| `DEBUG` | Detailed information, e.g. the functions retrieved during the `GenerateFunction` step, etc. | +| `INFO` | Confirming that the service is performing as expected. Each request is recorded once, by its audit entry. | +| `DEBUG` | Detailed information, e.g. the functions retrieved during the `GenerateFunction` step, each request's entry line, and the HTTP calls GraphRAG makes to other services. | | `DEBUG_PII` | Finer-grained information that could potentially include `PII`, such as a user’s question, the complete function call (with parameters), and the LLM’s natural language response. | | NOTSET | All messages are processed. | @@ -389,12 +390,12 @@ GraphRAG offers two chat engines, chosen from the chat menu (or set as a per-gra ### Agentic -The agent decides its **own** retrieval instead of following a fixed pipeline: it picks which methods to use (structural graph queries, vector search, community search), can call external [MCP tools](#mcp-servers-agentic-tools), answers greetings and questions about itself directly, and cites the chunks and queries it used. It comes in two styles: +The agent decides its **own** retrieval instead of following a fixed pipeline: it picks which methods to use (structural graph queries, vector search, community search), can call external [MCP tools](#mcp-servers-agentic-tools), answers greetings and questions about itself directly, reuses what the conversation already answers and retrieves only what is missing, and cites the chunks and queries it used. It comes in two styles: -- **Planned** *(default)* — analyzes the question up front and lays out the whole retrieval plan (which methods, how many, in what order) as a small DAG, executes it, then synthesizes one grounded answer. Predictable and efficient; a strong fit for most questions, including multi-part ones. +- **Planned** *(default)* — analyzes the question against the graph's schema up front, answers directly when the conversation or the schema already holds the answer (for example "what data do we have?"), and otherwise lays out the whole retrieval plan (which methods, how many, in what order) as a small DAG, executes it, then synthesizes one grounded answer. Predictable and efficient; a strong fit for most questions, including multi-part ones. - **Reactive** — reasons and acts one step at a time, deciding each next retrieval from what the previous step returned, and keeps going until it can answer completely and accurately. More adaptive to what it finds, at the cost of more steps (and tokens) on complex questions. -Both styles send trivial/conversational messages straight to a direct answer, and record their retrieval in the admin **Trace** — the plan/steps and reasoning, which chunks were retrieved, and which the agent selected for the answer. +Both styles send trivial/conversational messages straight to a direct answer, and record their retrieval in the admin **Trace** — the plan/steps and reasoning, the structured queries that ran, which chunks were retrieved, which the agent selected for the answer, and the error when an answer fails. **Selecting an engine.** In the chat UI, use the engine/style picker (Classic, or Agentic → Planned / Reactive). To set a per-graph default, configure `agent_style` (`auto` follows the configured default; `planned` or `reactive` force a style) — see [GraphRAG configuration](#graphrag-configuration). Depth is bounded by `agent_max_iterations` (Reactive) and `agent_max_replans` / `agent_max_total_steps` (Planned). @@ -546,6 +547,8 @@ Copy the below code into `configs/server_config.json`. You shouldn’t need to c | `agent_style` | string | `"planned"` | Default agentic engine style: `"planned"` (plan the whole retrieval up front) or `"reactive"` (decide each step from the last result). The chat menu can override per request. See [Chat Engines and Agents](#chat-engines-and-agents). | | `agent_max_iterations` | int | `30` | Reactive agent only: maximum reason-act-observe steps before it must answer. | | `agent_max_replans` | int | `3` | Planned agent only: how many times the planner may extend its plan when the gathered context is insufficient. | +| `cypher_max_attempts` | int | `10` | How many times a structured graph query may be generated for one question before GraphRAG gives up on it. | +| `planner_max_output_tokens` | int | `4000` | Planned agent only: the most output tokens the planner may use for one plan. Reasoning models use at least `16000`. | | `agent_max_total_steps` | int | `20` | Planned agent only: hard cap on executed retrieval steps across all replans. | | `chunk_only` | bool | `true` | If true, hybrid search only retrieves document chunks, excluding entity data. | | `doc_only` | bool | `false` | If true, hybrid search retrieves whole documents instead of chunks. Significantly increases context size. | @@ -695,6 +698,8 @@ In the `llm_config` section of `configs/server_config.json` file, copy JSON conf | `base_url` | string | No | — | Custom API endpoint URL. | | `token_limit` | int | No | inherited from top-level | Hard cap on token count for a prompt carrying retrieved context, prompt included. Context beyond it is truncated. `0` or omitted uses the model's own input limit when known, otherwise no cap. | +> **Gemini 3 models:** the agentic engine plans with temperature `1.0` when `model_kwargs` sets a lower one, as Google recommends for these models. Answers and knowledge graph building use the configured temperature. + **`embedding_service` parameters:** | Parameter | Type | Required | Default | Description | diff --git a/VERSION b/VERSION index 50ffc5aa..2165f8f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.3 +2.0.4 diff --git a/common/db/schema_utils.py b/common/db/schema_utils.py index 75d317d6..748171ef 100644 --- a/common/db/schema_utils.py +++ b/common/db/schema_utils.py @@ -1332,7 +1332,9 @@ def _collect_attrs(attr_list, skip_name: str) -> Tuple[dict, List[str]]: """Walk an ``Attributes`` array from ``getVertexType`` / ``getEdgeType`` and return ``({attr_name: tg_type}, ["name of type type", ...])``. ``skip_name`` is the primary-id attribute that - shouldn't appear in the user-facing schema rep. + shouldn't appear in the user-facing schema rep. A NULLABLE attribute + gets a " NULLABLE" suffix, so query generation knows which attributes + can hold NULL. """ attrs_map: dict = {} lines: List[str] = [] @@ -1342,7 +1344,8 @@ def _collect_attrs(attr_list, skip_name: str) -> Tuple[dict, List[str]]: if not a_name or a_name == skip_name: continue attrs_map[a_name] = a_type - lines.append(f"{a_name} of type {a_type}") + nullable = " NULLABLE" if a.get("isNullable") else "" + lines.append(f"{a_name} of type {a_type}{nullable}") return attrs_map, lines diff --git a/common/llm_services/base_llm.py b/common/llm_services/base_llm.py index 1b9a8de0..f0166fd4 100644 --- a/common/llm_services/base_llm.py +++ b/common/llm_services/base_llm.py @@ -638,12 +638,67 @@ def invoke_with_tools( _record_usage(caller_name, usage_data, self.config) return resp + def with_output_limit(self, max_output_tokens: int): + """The chat model with its output capped at ``max_output_tokens``. + + A per-call copy; the service's own model is not changed. The limit + field differs by provider (``max_tokens``, ``max_output_tokens``, + ``num_predict``). An existing lower limit is kept. Unknown models are + returned unchanged. + """ + llm = self.llm + if not max_output_tokens or llm is None or not hasattr(llm, "model_copy"): + return llm + model_kwargs = getattr(llm, "model_kwargs", None) + if isinstance(model_kwargs, dict) and "max_tokens" in model_kwargs: + # Bedrock: the service resolves its limit into model_kwargs. + current = model_kwargs["max_tokens"] or max_output_tokens + return llm.model_copy(update={"model_kwargs": { + **model_kwargs, "max_tokens": min(current, max_output_tokens)}}) + fields = getattr(type(llm), "model_fields", {}) + for name in ("max_tokens", "max_output_tokens", "num_predict"): + if name in fields: + current = getattr(llm, name, None) + limit = min(current, max_output_tokens) if current else max_output_tokens + return llm.model_copy(update={name: limit}) + return llm + + # Models already logged as getting the structured-output temperature. + _structured_temperature_logged: set = set() + + def with_structured_temperature(self, llm): + """``llm`` at the temperature structured output needs. + + Gemini 3 models can loop below temperature 1.0, so their structured + calls (planner, conversation check, triage) use 1.0 when the + configured temperature is lower. A per-call copy: answer generation + and ECC keep the configured temperature, and other models are + returned unchanged. + """ + from common.llm_services.capabilities import ( + GEMINI_3_TEMPERATURE, gemini_loops_below_default_temperature, + ) + model = ((getattr(self, "config", None) or {}).get("llm_model") or "") + if not gemini_loops_below_default_temperature(model) or not hasattr(llm, "model_copy"): + return llm + current = getattr(llm, "temperature", None) + if current is not None and current >= GEMINI_3_TEMPERATURE: + return llm + if model not in LLM_Model._structured_temperature_logged: + LLM_Model._structured_temperature_logged.add(model) + logger.info( + f"{model}: structured output uses temperature {GEMINI_3_TEMPERATURE} " + f"instead of {current} (Gemini 3 can loop below it)" + ) + return llm.model_copy(update={"temperature": GEMINI_3_TEMPERATURE}) + def invoke_structured( self, messages: list, schema, caller_name: str = "unknown", method: str = None, + max_output_tokens: int = None, ): """Invoke the chat model with native structured output. @@ -652,15 +707,18 @@ def invoke_structured( parse when the provider's structured-output path returns text. ``method`` is passed to ``with_structured_output`` when given; ``None`` - keeps the provider's default. + keeps the provider's default. ``max_output_tokens`` caps the output of + this call (see ``with_output_limit``). """ + llm = self.with_output_limit(max_output_tokens) if max_output_tokens else self.llm + llm = LLM_Model.with_structured_temperature(self, llm) usage_data = {} with get_openai_callback() as cb: try: if method is None: - structured = self.llm.with_structured_output(schema) + structured = llm.with_structured_output(schema) else: - structured = self.llm.with_structured_output(schema, method=method) + structured = llm.with_structured_output(schema, method=method) result = structured.invoke(messages) if result is None: # Tool-calling structured output returns None, not an @@ -673,7 +731,7 @@ def invoke_structured( "falling back to parser" ) parser = PydanticOutputParser(pydantic_object=schema) - raw = self.llm.invoke(messages) + raw = llm.invoke(messages) raw_text = self._message_text(raw) result = self._parse_or_repair(parser, raw_text, caller_name) usage_data["input_tokens"] = cb.prompt_tokens @@ -899,16 +957,22 @@ def generate_cypher_prompt(self): - Always include the entity from the `WHERE` clause in the final `RETURN`. Use vertex name over ID when available. - Always use **undirected** edge patterns. Ensure edges connect correct vertex types per schema. - Use **double quotes** for strings. -- For string comparisons in `WHERE`, convert with `toLower()`. +- For string comparisons in `WHERE` (`=`, `<>`, `IN`, `CONTAINS`, `STARTS WITH`, `ENDS WITH`), apply `toLower()` to the attribute and write the literal in lower case, e.g. `toLower(i.status) = "done"`. Empty-value tests (`= ""`, `<> ""`) need no `toLower()`. - Use multi-word, underscore-joined aliases for `ORDER BY`. Aliases / attributes used in `ORDER BY` must be in `RETURN`. Always specify `ASC` / `DESC` based on data type. - For "summarize" / "write a summary" questions, fetch all neighbour nodes and edges. -- Avoid invalid queries based on errors in the history above. +- For dates and times, use `to_datetime("YYYY-MM-DD hh:mm:ss")` (a date alone also works, e.g. `WHERE n.created >= to_datetime("2025-08-01")`). TigerGraph does not support `datetime()` or `date()`. +- Use `IS NULL` / `IS NOT NULL` only on an attribute that the schema marks `NULLABLE`. Any other attribute is never NULL: a value that is not set holds the default of its type. To test for a missing value on such an attribute, compare with that default: + - STRING: `= ""` / `<> ""` + - numbers: `= 0` / `<> 0` + - BOOL: `= false` + - DATETIME: `< to_datetime("1971-01-01")` / `>= to_datetime("1971-01-01")` (the default is 1970-01-01 00:00:00) +- Avoid invalid queries based on errors in the history above. Each error gives the line and column of the problem. ## Supported -- **Clauses**: `MATCH`, `OPTIONAL MATCH`, `MANDATORY MATCH`, `WHERE`, `RETURN`, `WITH`, `ORDER BY`, `SKIP`, `LIMIT`, `DELETE`, `DETACH DELETE` +- **Clauses**: `MATCH`, `OPTIONAL MATCH`, `MANDATORY MATCH`, `WHERE`, `RETURN`, `WITH`, `ORDER BY`, `SKIP`, `LIMIT` - **Operators**: - Math: `+`, `-`, `*`, `/`, `%`, `^` - - Comparison: `=`, `<`, `<=`, `>`, `>=`, `<>`, `IS NULL`, `IS NOT NULL` + - Comparison: `=`, `<`, `<=`, `>`, `>=`, `<>`, `IS NULL` and `IS NOT NULL` (`NULLABLE` attributes only) - Boolean: `AND`, `OR`, `NOT`, `XOR` - String / list: `CONTAINS`, `STARTS WITH`, `ENDS WITH`, `IN`, `DISTINCT`, `[ ]`, `.` - **Functions**: @@ -916,17 +980,20 @@ def generate_cypher_prompt(self): - Math: `abs`, `sqrt`, `log`, `exp`, `sin`, `cos`, `tan`, `radians`, `degrees` - String: `left`, `right`, `substring`, `replace`, `trim`, `toLower`, `toUpper`, `split` - List: `head`, `last`, `size`, `range`, `coalesce`, `tail` + - Date / time: `to_datetime`, `datetime_format`, `datetime_to_epoch`, `year` - Other: `id`, `elementId`, `labels`, `properties`, `timestamp` - **Expressions**: `CASE` ## Unsupported -- **Clauses**: `CALL`, `CREATE`, `MERGE`, `REMOVE`, `SET`, `UNION`, `UNION ALL`, `UNWIND` -- **Functions**: `collect`, `exists`, `keys`, `nodes`, `relationships`, `length`, `percentileCont`, `percentileDisc`, `startNode`, `endNode`, `reverse` (list form) +- **Clauses**: `CALL`, `CREATE`, `MERGE`, `REMOVE`, `SET`, `DELETE`, `DETACH DELETE`, `UNION`, `UNION ALL`, `UNWIND` (queries only read data) +- **Functions**: `datetime`, `date`, `collect`, `exists`, `keys`, `nodes`, `relationships`, `length`, `percentileCont`, `percentileDisc`, `startNode`, `endNode`, `reverse` (list form) - **Syntax limits**: - `WITH` must group by exactly one vertex variable. - Path variables (`p = (...)`) not supported. - `MATCH` must reference variables from prior `WITH`. - Disconnected `MATCH` fragments not supported. + - Do not combine `STARTS WITH`, `ENDS WITH` or `CONTAINS` with `OPTIONAL MATCH`, `CASE`, `IS NULL` or `IS NOT NULL` in one query: TigerGraph cannot run it. + - Use `MATCH`, not `OPTIONAL MATCH`, when the question filters on the related vertex: a `WHERE` after `OPTIONAL MATCH` does not filter the rows. ## Output - The query must return both the entity from the question AND the requested data. @@ -1069,7 +1136,7 @@ def select_retriever_prompt(self): _AGENTIC_AGENT_SYSTEM = """\ You are a GraphRAG agent answering questions over a TigerGraph knowledge graph. -You have a set of read-only tools (graph schema via graphrag__get_schema, structural query generation, several unstructured retrievers, raw GSQL via tg_run_query, neighbor expansion). The graph schema is NOT pre-loaded — fetch it with graphrag__get_schema when you need it. +You have a set of read-only tools (graph schema via graphrag__get_schema, structural query generation, several unstructured retrievers). The graph schema is NOT pre-loaded — fetch it with graphrag__get_schema when you need it. REASON, ACT, OBSERVE — repeat until you can give a complete, well-grounded answer. @@ -1078,9 +1145,17 @@ def select_retriever_prompt(self): - If not — a part is still unanswered, a needed value or table is missing, or the results were thin — take another action to close the gap (follow a lead, widen top_k / num_hops, or switch method). Do not settle for a partial or vague answer when more retrieval could complete it. Do not commit to a full multi-step plan up front; let each next step be driven by what is still missing for a complete answer. +Before your first tool call, list the facts the question asks for (values, names, dates). For each fact, check whether an earlier answer in ## Conversation states it explicitly. A related topic or a similar question is not enough. First judge each earlier answer that you would use. A bad answer counts as not answered, and a fact in a bad answer is not stated. An answer is bad when it says that the information was not found, not available, not explicit or not in the context; when it hedges or guesses (for example "but", "may", "it seems", "it is unclear"); when it gives only part of what was asked, or an approximate value; or when it answers a different question. Then: +- Every fact is stated (for example, the same question was already asked and answered well): give the final answer without calling a tool. +- Some facts are stated: call tools only for the missing facts. Do not retrieve again what ## Conversation already states. Combine both in the final answer. +- No fact is stated: retrieve as described below. +This decision comes before the "Retrieval Strategy" below. +Do not use an earlier answer when the question asks for the current or latest state (for example "now", "current", "latest", "again"), or when the user says that an earlier answer was wrong or incomplete. +When an earlier answer to the same question was bad, retrieve again with a different or wider approach than a single search (for example both structural and unstructured retrieval, or a higher top_k). + The graph schema is required for the structural and unstructured query tools: before your first structural query or vector/unstructured retrieval, call graphrag__get_schema once to load the graph's vertex and edge types. Questions answered without graph data (e.g. by an external tool) do not need the schema. -Run independent tool calls in parallel within one response; chain dependent calls across iterations. Cite specific findings from tool results in your final answer. +Run independent tool calls in parallel within one response; chain dependent calls across iterations. Cite specific findings from tool results in your final answer. When the tool results do not answer the question, or a part of it, say so plainly for that part instead of guessing. Then ask the user to rephrase the question with more specific information or a narrower scope, for example the exact name, time period, or document. Write this in the user's language. Choose WHICH retrieval methods to use, and when, per the "Retrieval Strategy" below. @@ -1123,7 +1198,22 @@ def agentic_agent_prompt(self): - in what order. Express this as a small DAG of tool steps that gathers exactly the context needed, ending with one final "answer" step that consolidates all the gathered context into the response. Express ordering with depends_on and repetition with multiple steps. -The graph schema is NOT provided here — the structural and unstructured query tools load it themselves at run time, so plan retrieval steps directly. A question that needs no graph data should not include any graph-retrieval step (plan only the final answer step, or the relevant non-graph tool). +## Graph schema lists the vertex types (with their attribute names) and the edge types of the graph. Use it to decide whether the question needs STRUCTURAL retrieval: plan a structural step when the question asks for data that these types and attributes hold (counts, lookups, relationships, aggregations). The query tools load the full schema themselves, so do not plan a schema step. The schema itself answers questions about the knowledge base (what data, datasets, types or sources it has, what the graph is about): its type names and descriptions are the answer, so such a question needs no retrieval step: list the facts it gives in stated_by_context. The schema never answers questions about values in the data (counts, names, dates of records). A question that needs no graph data should not include any graph-retrieval step (plan only the final answer step, or the relevant non-graph tool). + +Before you plan, write "context_review" (it comes first in your output). Judge the context you were given strictly, as a reviewer would. Each context section has its key in its heading, for example "## Graph schema (context: graph_schema)": +- needs_data: false only when the question needs no information at all (for example a calculation or a greeting). A question answered from ## Conversation or ## Graph schema needs data: set true, and let stated_by_context and coverage show that the context has it. +- facts_asked: the facts the question asks for (values, names, dates). +- earlier_answer: the quality of the earlier answer you would reuse. It is "bad" when it says that the information was not found, not available, not explicit or not in the context; when it hedges or guesses (for example "but", "may", "it seems", "it is unclear"); when it gives only part of what was asked or an approximate value; when it uses a different attribute than the question asks for (for example an updated date for a created date); or when it answers a different question. It is "none" when no earlier answer is about this question. +- stated_by_context: only the facts that a context section states explicitly: a good earlier answer in ## Conversation, ## Graph schema (for questions about the knowledge base itself), or another section. A fact in a bad answer is not stated. A related topic or a similar question is not enough. +- coverage: how many of facts_asked the context states: "all", "part" or "none". +Then plan: +- Plan only the final answer step ONLY when coverage is "all" and earlier_answer is not "bad", or when needs_data is false. +- When coverage is "part", plan retrieval steps only for the missing facts. Do not retrieve again what the context states. The answer step combines both. +- Set the answer step's args.context to the keys of the context sections that state facts for the answer, for example {"context": ["graph_schema"]}. For ## Conversation, name each good earlier turn that states facts for the answer by its "turn" number, for example {"context": ["conversation#2"]}, or {"context": ["conversation#1", "conversation#3"]} when several turns are needed. The answer step gets those sections and turns as you see them. A plan with only the answer step must set it. +- Otherwise plan the retrieval steps as described below. If you are not sure, retrieve. +Do not use an earlier answer when the question asks for the current or latest state (for example "now", "current", "latest", "again"), or when the user says that an earlier answer was wrong or incomplete. +When an earlier answer to the same question was bad, retrieve again with a different or wider approach than a single search (for example both structural and unstructured retrieval, or a higher top_k). +This decision comes before the "Retrieval Strategy" below. You have two kinds of retrieval: - STRUCTURAL (graphrag__structural_retrieve): generates and runs a graph query. Best for counts, lookups by attribute/id, relationships, and aggregations over typed data. It depends on the LLM generating a correct query against the live schema — it can return nothing or the wrong rows when the question doesn't map cleanly to typed graph data, so it is NOT a safe sole source of context. @@ -1134,6 +1224,7 @@ def agentic_agent_prompt(self): - Retrieval params (top_k, num_hops, community_level) are optional; omit them to use defaults, or set higher values when you expect a broad answer. - For each unstructured step, set args.question to that clause only as a standalone search query in the user's language. Do not pass the full multi-part question, a clause already assigned to another step, or an unresolved reference from conversation history. - The final step MUST have kind="answer" and tool="" (the orchestrator synthesizes the answer from gathered context); it should depend_on all retrieval steps. +- Write "context_review" first, then every step. Then set "strategy" to one short sentence (under 25 words). Give each step a one-sentence "rationale". Decide which retrievals to include, how many, and in what order using the "Retrieval Strategy" below. Return ONLY the structured plan. @@ -1235,6 +1326,10 @@ def hyde_prompt(self): ## Rules - The contexts arrive as JSON key-context pairs. **Combine and rephrase** them to answer the question. - **Preserve** image links exactly as `![description](url)` in the final answer when used. Do NOT modify or omit them. +- When the contexts do not answer the question, or a part of it, say so plainly for that part instead of guessing. Then ask the user to rephrase the question with more specific information or a narrower scope, for example the exact name, time period, or document. Write this in the user's language. +- The context "earlier_conversation", when present, holds earlier questions and answers from this chat. Use it to resolve references, and to answer when it holds the answer. When it conflicts with another context, the other context is correct. An earlier answer that says information was not found is not evidence that it does not exist. +- The context "conversation_selected", when present, holds the earlier turns from "earlier_conversation" that state the facts for this answer. Answer from them; use the other turns only to resolve references. +- The context "graph_schema", when present, describes the graph this chat uses: its name, and its vertex and edge types with their descriptions. Use it to answer questions about the knowledge base itself (what data or datasets it has, what the graph is about) from the type names and descriptions. Describe the data in plain words. Do not list GraphRAG's own types (Document, DocumentChunk, Content, Entity, EntityType, RelationshipType, Community, Image) one by one: together they are the uploaded documents and the knowledge extracted from them. Do not state counts or values that the schema does not give. ## Inputs - **Question**: {question} diff --git a/common/llm_services/capabilities.py b/common/llm_services/capabilities.py index 36140c1c..d988ada2 100644 --- a/common/llm_services/capabilities.py +++ b/common/llm_services/capabilities.py @@ -93,6 +93,20 @@ def _openai_thinking(model: str) -> bool: ) +# Gemini 3 models loop in structured output below this temperature. +GEMINI_3_TEMPERATURE = 1.0 + + +def gemini_loops_below_default_temperature(model: str) -> bool: + """Gemini 3 models, which Google says can loop below temperature 1.0 + ("Gemini 3 developer guide"). Case-insensitive; accepts a ``models/`` + prefix.""" + m = (model or "").strip().lower() + if m.startswith("models/"): + m = m[len("models/"):] + return m.startswith("gemini-3") + + def openai_rejects_temperature(model: str) -> bool: """OpenAI o-series reasoning models (o1/o3/o4) reject a custom ``temperature`` — only the default value is accepted, and sending the diff --git a/common/logs/log.py b/common/logs/log.py index 94520267..a208aa81 100644 --- a/common/logs/log.py +++ b/common/logs/log.py @@ -91,5 +91,9 @@ def logToRoot(message, *args, **kwargs): logging.basicConfig(level=LOGLEVEL, handlers=handlers) +# httpx logs every outgoing request (chat history loads, LLM provider calls) at +# INFO; keep those for DEBUG so request logs stay readable. +for _noisy in ("httpx", "httpcore"): + logging.getLogger(_noisy).setLevel(logging.DEBUG if LOGLEVEL == "DEBUG" else logging.WARNING) req_id_cv = contextvars.ContextVar("req_id", default=None) diff --git a/common/py_schemas/schemas.py b/common/py_schemas/schemas.py index 6ee296f9..87d55eaf 100644 --- a/common/py_schemas/schemas.py +++ b/common/py_schemas/schemas.py @@ -13,9 +13,11 @@ # along with this program. If not, see . import enum -from typing import Dict, List, Optional, Union +import logging +import re +from typing import Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator class ConversationTurn(BaseModel): @@ -68,10 +70,55 @@ class GraphRAGResponse(BaseModel): answered_question: bool response_type: str query_sources: Dict = None + # The error that stopped the answer, for the trace log only. Private, so + # it is never serialized into the chat response or the chat history. + _error: Optional[str] = PrivateAttr(default=None) # --- Agentic engine (v2.0 deep-thinking mode) ------------------------------ +logger = logging.getLogger(__name__) + +# Length bound for the planner's free-text fields. +_PLAN_TEXT_MAX = 200 + + +# A unit of 1-8 characters repeated 12+ times in a row, e.g. "2026.0000…". +_CHAR_RUN = re.compile(r"(.{1,8}?)\1{11,}", re.S) + + +def _collapse_repeats(text: str, min_words: int = 4, max_words: int = 60) -> str: + """Cut ``text`` at the first runaway repetition. + + Two shapes are caught: a short run of characters repeated in place + ("2026.0000…"), cut where the run starts; and a phrase of + ``min_words``–``max_words`` words that repeats three times in a row, + keeping one copy of it. + """ + run = _CHAR_RUN.search(text) + if run: + text = text[:run.start()].rstrip(" .,;:-") + words = text.split() + for i in range(len(words)): + for n in range(min_words, max_words + 1): + if i + 3 * n > len(words): + break + if words[i:i + n] == words[i + n:i + 2 * n] == words[i + 2 * n:i + 3 * n]: + return " ".join(words[:i + n]) + return text + + +def _short_plan_text(value, field: str): + """Shorten an overlong plan text instead of rejecting the whole plan.""" + if not isinstance(value, str) or len(value) <= _PLAN_TEXT_MAX: + return value + text = _collapse_repeats(value) + if len(text) > _PLAN_TEXT_MAX: + text = text[:_PLAN_TEXT_MAX - 1].rstrip() + "…" + logger.warning(f"agentic plan: shortened {field} from {len(value):,} to {len(text):,} chars") + return text + + class PlanStep(BaseModel): """One step in an agentic plan DAG. @@ -86,12 +133,107 @@ class PlanStep(BaseModel): args: Dict = {} arg_bindings: Dict[str, str] = {} depends_on: List[str] = [] - rationale: str = "" + rationale: str = Field( + "", + description="One short sentence on why this step is needed.", + json_schema_extra={"maxLength": _PLAN_TEXT_MAX}, + ) + + @field_validator("rationale", mode="before") + @classmethod + def _short_rationale(cls, v): + return _short_plan_text(v, "rationale") + + +# Bounds for the planner's conversation review (hints in the schema; longer +# values are cut, never rejected). +_REVIEW_MAX_ITEMS = 8 +_REVIEW_ITEM_CHARS = 120 + + +def _short_items(value): + if not isinstance(value, list): + return value + return [str(v)[:_REVIEW_ITEM_CHARS] for v in value[:_REVIEW_MAX_ITEMS]] + + +class ContextReview(BaseModel): + """The planner's review of the context it was given (## Conversation, + ## Graph schema and any other section), written before the steps. + + It decides whether the plan may skip retrieval: only when the context + states every fact asked for, or the question needs no data. + """ + needs_data: bool = Field( + True, + description="False only when the question needs no information at all (for example a " + "calculation). True when the answer comes from the conversation or the schema.", + ) + facts_asked: List[str] = Field( + default_factory=list, + description="The facts the question asks for (values, names, dates), each in a few words.", + json_schema_extra={"maxItems": _REVIEW_MAX_ITEMS}, + ) + earlier_answer: Literal["good", "bad", "none"] = Field( + "none", + description="The quality of the earlier answer you would reuse: good, bad, or none " + "when no earlier answer is about this question.", + ) + stated_by_context: List[str] = Field( + default_factory=list, + description="The facts from facts_asked that the context states explicitly: a GOOD " + "earlier answer, the graph schema, or another section you were given.", + json_schema_extra={"maxItems": _REVIEW_MAX_ITEMS}, + ) + coverage: Literal["all", "part", "none"] = Field( + "none", + description="all: the context states every fact asked; part: some; none: no fact.", + ) + + @field_validator("facts_asked", "stated_by_context", mode="before") + @classmethod + def _short_lists(cls, v): + return _short_items(v) + + @field_validator("earlier_answer", "coverage", mode="before") + @classmethod + def _known_value(cls, v, info): + allowed = {"earlier_answer": ("good", "bad", "none"), "coverage": ("all", "part", "none")} + v = str(v or "").strip().lower() + return v if v in allowed[info.field_name] else "none" + + +def _require_plan_fields(schema: dict) -> None: + # All three are required: a plan without its steps cannot run. + schema["required"] = ["context_review", "steps", "strategy"] class Plan(BaseModel): - steps: List[PlanStep] = [] - strategy: str = "" # one-line, user-facing summary + # Field order is the order the model writes them: review, steps, strategy. + model_config = ConfigDict(json_schema_extra=_require_plan_fields) + + context_review: ContextReview = Field( + default_factory=ContextReview, + description="Write this first: your review of the context you were given, before you plan.", + ) + steps: List[PlanStep] = Field( + default_factory=list, + description="The plan steps, ending with the final answer step.", + ) + # Steps the planner wrote that named an unknown tool and were dropped. + # Shown in the trace only; never part of the model's output schema. + _dropped_steps: list = PrivateAttr(default_factory=list) + strategy: str = Field( + "", + description="One short sentence (under 25 words) on the approach. " + "Write it after the steps.", + json_schema_extra={"maxLength": _PLAN_TEXT_MAX}, + ) + + @field_validator("strategy", mode="before") + @classmethod + def _short_strategy(cls, v): + return _short_plan_text(v, "strategy") class StepResult(BaseModel): diff --git a/common/requirements.txt b/common/requirements.txt index 17892bfc..d20bec7a 100644 --- a/common/requirements.txt +++ b/common/requirements.txt @@ -103,7 +103,9 @@ mypy-extensions>=1.1.0 nest-asyncio>=1.6.0 nltk>=3.9.1 numpy>=1, <2 -openai>=1.92.2 +# openai 3.14.1 and later break Regenerate embeddings. +# TODO(GML-2346): remove this limit when each event loop has its own client. +openai>=2.45,<3 openpyxl>=3.1.0 xlrd>=2.0.1 ordered-set>=4.1.0 @@ -184,10 +186,11 @@ wrapt>=1.17.2 yarl>=1.20.1 zipp>=3.23.0 -# Agentic engine (v2.0) — MCP + tigergraph-mcp for in-process, per-user -# tool execution. Requires the fastapi/starlette bump above (mcp pulls -# starlette>=0.49). -mcp>=1.27.1 +# Agentic engine (v2.0) — MCP client for add-on MCP servers. tigergraph-mcp +# is kept for the planned built-in in-process server (GML-2330). Requires the +# fastapi/starlette bump above (mcp pulls starlette>=0.49). mcp 2.x changes +# the client API that mcp_addons uses. +mcp>=1.27.1,<2 tigergraph-mcp>=1.0.4 sse-starlette>=3.4.4 httpx-sse>=0.4.3 diff --git a/common/utils/query_results.py b/common/utils/query_results.py new file mode 100644 index 00000000..a6c6e759 --- /dev/null +++ b/common/utils/query_results.py @@ -0,0 +1,84 @@ +# Copyright (c) 2024-2026 TigerGraph, Inc. +# +# This program may be redistributed and/or modified under the terms of the GNU +# Affero General Public License as published by the Free Software Foundation, +# either version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Whether a graph query result holds any rows. + +Shared by the agentic structural tool and the classic engine, so both treat +the same result the same way. +""" + +import json +from typing import Any + + +def is_empty_result(result: Any) -> bool: + """True when ``result`` holds no rows. + + Results arrive as JSON text (pyTigerGraph function calls) or as nested + containers (openCypher, e.g. ``{"VS_XXX": []}``), so both are read + through. A container is empty when every value in it is empty. A scalar + such as a count of 0 is an answer, not an empty result. + """ + if result is None: + return True + if isinstance(result, str): + text = result.strip() + if not text: + return True + try: + parsed = json.loads(text) + except ValueError: + return False + if parsed is None or isinstance(parsed, (list, dict)): + return is_empty_result(parsed) + return False + if isinstance(result, dict): + return all(is_empty_result(v) for v in result.values()) + if isinstance(result, (list, tuple, set, frozenset, range)): + return all(is_empty_result(v) for v in result) + return False + + +def count_rows(result: Any) -> int: + """How many rows a graph query result holds, for summaries. + + A list holds one row per item, except that a list of result blocks + (``[{"VS": [...]}]``) is counted through. A dict holding lists + (``{"VS_XXX": [...]}``) counts the list items; a dict of plain values + (``{"T": {"n": 4}}``) is one row. JSON text is read first. + """ + if result is None: + return 0 + if isinstance(result, str): + try: + result = json.loads(result) + except ValueError: + return 1 if result.strip() else 0 + if isinstance(result, list): + if result and all(isinstance(r, dict) and any(isinstance(v, list) for v in r.values()) for r in result): + return sum(count_rows(r) for r in result) + return len(result) + if isinstance(result, dict): + lists = [v for v in result.values() if isinstance(v, list)] + if lists: + return sum(len(v) for v in lists) + return 0 if is_empty_result(result) else 1 + return 1 + + +def describe_rows(source: str, result: Any) -> str: + """A one-line summary like "graph query returned 4 rows, 1,234 chars".""" + n = count_rows(result) + text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str) + return f"{source} returned {n:,} row{'' if n == 1 else 's'}, {len(text):,} chars" diff --git a/common/utils/user_messages.py b/common/utils/user_messages.py new file mode 100644 index 00000000..91a92277 --- /dev/null +++ b/common/utils/user_messages.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024-2026 TigerGraph, Inc. +# +# This program may be redistributed and/or modified under the terms of the GNU +# Affero General Public License as published by the Free Software Foundation, +# either version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Chat messages for a question GraphRAG could not answer. + +One wording for every engine and endpoint, so a user who gets no answer is +always told how to ask again. +""" + +RETRY_HINT = ( + "Try rephrasing the question with more specific information or a narrower " + "scope, such as the exact name, time period, or document you mean." +) + +# No answer was found for the question. +NO_ANSWER = "I couldn't find an answer to this question in the knowledge graph. " + RETRY_HINT + +# Generating the answer failed. +ANSWER_FAILED = ( + "I wasn't able to generate an answer for this question. Try asking again. " + RETRY_HINT +) diff --git a/ecc/app/graphrag/regenerate.py b/ecc/app/graphrag/regenerate.py index 45f760e5..7f5c71ae 100644 --- a/ecc/app/graphrag/regenerate.py +++ b/ecc/app/graphrag/regenerate.py @@ -116,10 +116,15 @@ async def regenerate_embeddings(graphname, conn): skipped += 1 continue try: - await store.aadd_embeddings([(text, [])], [{"vertex_id": (vid, vt)}]) - regenerated += 1 + # Only a returned result means the embedding was saved. + written = await store.aadd_embeddings([(text, [])], [{"vertex_id": (vid, vt)}]) except Exception as e: logger.warning(f"regen_embeddings: re-embed failed {vt} {vid}: {e}") + written = None + if written: + regenerated += 1 + else: + logger.warning(f"regen_embeddings: no embedding saved for {vt} {vid}") skipped += 1 logger.info( f"regenerate_embeddings({graphname}): " @@ -191,13 +196,19 @@ async def regenerate_summaries(graphname, conn): "Community", pid, attributes={"description": summary, "iteration": i}, ) - await store.aadd_embeddings( + written = 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 + continue + if written: + resummarized += 1 + else: + # A summary saved without its embedding is not counted as done. + logger.warning(f"regen_summaries: summary saved but no embedding for {cid}") + skipped += 1 logger.info( f"regenerate_summaries({graphname}): " f"resummarized={resummarized} skipped={skipped}" diff --git a/graphrag-ui/src/pages/TraceLogs.tsx b/graphrag-ui/src/pages/TraceLogs.tsx index 185b86fe..2a8d48b0 100644 --- a/graphrag-ui/src/pages/TraceLogs.tsx +++ b/graphrag-ui/src/pages/TraceLogs.tsx @@ -132,6 +132,7 @@ const NODE_LABELS: Record = { rewrite_question: "Rewrite Question", apologize: "Apologize", greet: "Greet", + error: "Error", }; function buildTraceFromMessage(message: any, userQuery?: string): TraceData { @@ -359,7 +360,7 @@ const ExpandableRow: FC<{ {open && content && (
-
{content}
+
{content}
)} @@ -390,7 +391,7 @@ const PlanPanel: FC<{ trace: TraceData }> = ({ trace }) => { {plan.strategy && (
Strategy -

{plan.strategy}

+

{plan.strategy}

)}
    @@ -552,7 +553,7 @@ const ToolCallExpandable: FC<{ tc: ToolCallEntry }> = ({ tc }) => {

    Input

    -
    +            
                   {tc.input || "N/A"}
                 
    @@ -560,7 +561,7 @@ const ToolCallExpandable: FC<{ tc: ToolCallEntry }> = ({ tc }) => {

    Output

    -
    +            
                   {tc.output || "N/A"}
                 
    @@ -867,7 +868,7 @@ const TraceLogs: FC = ({ messageIdProp, onClose }) => { return ( !o && onClose && onClose()}> e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()} > @@ -1075,8 +1076,12 @@ const TraceLogs: FC = ({ messageIdProp, onClose }) => { {/* Footer — Download (primary action style) + Close (outline, matches other dialogs). Replaces the top-right Download in - dialog mode so the dialog can size to content. */} -
    + dialog mode so the dialog can size to content. In the dialog it + stays at the bottom while the trace scrolls, so Close is always + visible. */} +
    {isDialog && (