diff --git a/bcg/config/config.example.yaml b/bcg/config/config.example.yaml index 178f54c7..59f14023 100644 --- a/bcg/config/config.example.yaml +++ b/bcg/config/config.example.yaml @@ -92,12 +92,12 @@ pipeline: enable_thinking: false fail_on_error: true search_previous_turns: true - max_previous_windows: 4 + max_previous_windows: 3 runtime: evidence_mode: chunk context_chars: 12000 # 0 = all recent nodes within context_chars; 2 = latest two Graph turns. - extraction_history_turns: 0 + extraction_history_turns: 2 min_content_len: 0 tool_results: max_search_results: 10 diff --git a/bcg/config/defaults.yaml b/bcg/config/defaults.yaml index abcc40ca..c93176fe 100644 --- a/bcg/config/defaults.yaml +++ b/bcg/config/defaults.yaml @@ -30,7 +30,7 @@ pipeline: # Node-extraction history window. 0 keeps every recent node that fits # context_chars; positive values keep only nodes from the latest N # non-empty Graph turns before applying the character budget. - extraction_history_turns: 0 + extraction_history_turns: 2 min_content_len: 0 tool_results: max_search_results: 10 diff --git a/bcg/construct/hybrid/edge_generation.py b/bcg/construct/hybrid/edge_generation.py index 6d7dc5ce..71df50e2 100644 --- a/bcg/construct/hybrid/edge_generation.py +++ b/bcg/construct/hybrid/edge_generation.py @@ -64,7 +64,7 @@ def normalize_edge_config(config: Mapping[str, Any] | None) -> dict[str, Any]: "enable_thinking": bool(raw["enable_thinking"]), "fail_on_error": bool(raw["fail_on_error"]), "search_previous_turns": bool(raw["search_previous_turns"]), - "max_previous_windows": max(1, int(raw.get("max_previous_windows", 4))), + "max_previous_windows": max(1, int(raw.get("max_previous_windows", 3))), } diff --git a/bcg/construct/hybrid/prompts.py b/bcg/construct/hybrid/prompts.py index 06cf3502..9cd46a1d 100644 --- a/bcg/construct/hybrid/prompts.py +++ b/bcg/construct/hybrid/prompts.py @@ -336,13 +336,14 @@ def build_chunk_extraction_prompt( if is_assistant: parts.append(_DECISION_DEFINITION) - parts.append( - "## Existing belief nodes (context — READ ONLY, no relations)\n" - "These nodes were extracted from EARLIER turns. Use them only to resolve " - "pronouns/vague references and to keep entity names and wording consistent. " - "Do NOT copy them as output.\n" - f"{GRAPH_NODES_PLACEHOLDER}\n" - ) + if (graph_nodes or "").strip() not in {"", "[]", "null", "None"}: + parts.append( + "## Existing belief nodes (context — READ ONLY, no relations)\n" + "These nodes were extracted from EARLIER turns. Use them only to resolve " + "pronouns/vague references and to keep entity names and wording consistent. " + "Do NOT copy them as output.\n" + f"{GRAPH_NODES_PLACEHOLDER}\n" + ) if ( turn_content and turn_content.strip() diff --git a/bcg/construct/unified/extract.py b/bcg/construct/unified/extract.py index 3b29fcea..f925b7e6 100644 --- a/bcg/construct/unified/extract.py +++ b/bcg/construct/unified/extract.py @@ -81,6 +81,7 @@ def _clean_node( ordinal: int, *, node_type: str, + preserve_model_tmp_id: bool = False, ) -> dict[str, Any] | None: """Validate / coerce one belief or decision object from the model.""" if not isinstance(raw, dict): @@ -95,10 +96,16 @@ def _clean_node( else: return None - tmp = raw.get("tmp_id") - if not isinstance(tmp, str) or not tmp.strip(): - tmp = f"n{ordinal}" - tmp = tmp.strip() + # Two-phase extraction does not need the model to invent temporary ids: + # relations are generated later against code-owned global ids. Keep the + # model-provided value only for the legacy single-response node+edge API, + # where relations in that same response must reference temporary ids. + prefix = "d" if node_type == "decision" else "n" + tmp = f"{prefix}{ordinal}" + if preserve_model_tmp_id: + model_tmp = raw.get("tmp_id") + if isinstance(model_tmp, str) and model_tmp.strip(): + tmp = model_tmp.strip() primary_text_key = "decision" if node_type == "decision" else "belief" out: dict[str, Any] = { @@ -952,10 +959,42 @@ def format_graph_nodes( return "[\n" + ",\n".join(items) + "\n]" +def format_extraction_nodes( + nodes: list[dict[str, Any]], char_budget: int | None = 9000 +) -> str: + """Render only prior semantic content needed during node extraction.""" + if not nodes: + return "[]" + ordered = sorted(nodes, key=lambda b: b.get("id", 0)) + lines: list[str] = [] + for node in ordered: + content = ( + node.get("decision") + if node.get("node_type") == "decision" + else node.get("belief") + ) + content = content or node.get("belief") or node.get("decision") or "" + if len(content) > 240: + content = content[:220] + " …" + lines.append(json.dumps({"content": content}, ensure_ascii=False)) + + total = sum(len(line) + 4 for line in lines) + omitted = 0 + while char_budget is not None and lines and total > char_budget: + total -= len(lines[0]) + 4 + lines.pop(0) + omitted += 1 + items = ( + [f" (... {omitted} earlier node(s) omitted for length ...)"] if omitted else [] + ) + items += [" " + line for line in lines] + return "[\n" + ",\n".join(items) + "\n]" + + def format_relation_nodes( nodes: list[dict[str, Any]], char_budget: int | None = 9000 ) -> str: - """Render only the semantic fields needed for Assistant relation judgment.""" + """Render only the semantic fields needed for relation judgment.""" if not nodes: return "[]" ordered = sorted(nodes, key=lambda b: b.get("id", 0)) @@ -1124,7 +1163,14 @@ def update_graph( ordinal = 0 for b in parsed.get("beliefs", []) or []: - cb = _clean_node(b, mode, n_sentences, ordinal, node_type="belief") + cb = _clean_node( + b, + mode, + n_sentences, + ordinal, + node_type="belief", + preserve_model_tmp_id=True, + ) if cb is None: continue if cb["tmp_id"] in seen_tmp: @@ -1147,7 +1193,14 @@ def update_graph( out_nodes.extend(out_beliefs[parsed_belief_count:]) for d in parsed.get("decisions", []) or []: - cd = _clean_node(d, mode, n_sentences, ordinal, node_type="decision") + cd = _clean_node( + d, + mode, + n_sentences, + ordinal, + node_type="decision", + preserve_model_tmp_id=True, + ) if cd is None: continue if cd["tmp_id"] in seen_tmp: diff --git a/bcg/construct/unified/prompts.py b/bcg/construct/unified/prompts.py index 3a400c50..e5ccdb29 100644 --- a/bcg/construct/unified/prompts.py +++ b/bcg/construct/unified/prompts.py @@ -92,7 +92,7 @@ - generic filler: "the content", "this turn", "the answer", "the issue", "the thing", "the result"; - bare generic nouns: car, file, code, graph, node, edge, model, prompt, time, data, message, unless they are qualified enough to be identifiable; - abstract feelings or vague concepts unless the belief is specifically about that concept; -- temporal expressions as entities; preserve temporal information in the belief or decision text instead (event metadata is assigned by the graph builder); +- temporal expressions as entities; preserve temporal information in the belief or decision text instead; - duplicate surface forms referring to the same entity in one belief. Use the most specific form supported by the CURRENT turn and existing graph context. @@ -123,8 +123,46 @@ - **judged** — evaluative conclusion, recommendation, ranking, diagnosis, or selected option ("most likely", "best answer is", "I recommend X"). -NOTE: do NOT output a confidence number — confidence is assigned downstream by code -rules based on (role, stance). +""" + +_USER_BELIEF_DEFINITION = """\ +## What is a belief +A belief is a self-contained memory or reasoning unit, usually shaped like: + + +Preserve the most specific supported wording. Each belief must express one +reusable semantic unit and remain understandable outside the original turn. + +## Granularity +Merge clauses that jointly define one setup, condition, event, or causal step. +Split only propositions that can be independently confirmed, contradicted, or +reused. Do not merge independently searchable numbered clues solely because +they describe the same target. Keep claims with different epistemic status +separate. + +## Entities +For each belief, list specific named or uniquely qualified people, +organizations, places, products, files, tools, models, APIs, datasets, and +concepts explicitly present in it. Exclude pronouns, temporal expressions, +bare generic nouns, vague concepts, and duplicates. Include task-defining +qualified roles when they distinguish reusable constraints, such as "winning +team", "first poet", or "target ODI match". Use [] when none exists. +""" + +_USER_STANCE_DEFINITION = """\ +## Stance +Choose one per belief: asserted = direct statement; recalled = explicit memory; +speculated = uncertain possibility; judged = assessment, recommendation, or +conclusion. +""" + +_USER_GUIDANCE = """\ +## Source role: USER +Extract the user's substantive request, facts, events, preferences, plans, +constraints, questions, corrections, and updates. Rewrite questions as +self-contained statements about what the user wants to know. Preserve related +constraints together. Write in the third person about "The user" or the named +subject. Skip greetings and purely cosmetic instructions. """ _GRAPH_CONTEXT_BLOCK = f"""\ @@ -167,6 +205,13 @@ {GRAPH_NODES_PLACEHOLDER} """ + +def _has_existing_nodes(graph_nodes: str | None) -> bool: + """Return whether a rendered graph-node block contains any nodes.""" + + return (graph_nodes or "").strip() not in {"", "[]", "null", "None"} + + _FORWARD_EDGE_RULES = """\ ## Relations between nodes After creating the NEW beliefs/decisions for this turn, emit relations that connect: @@ -242,8 +287,6 @@ "belief": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], - "tool_name": "", - "query": "", "supporting_excerpts": [""] } ], @@ -271,8 +314,6 @@ "belief": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], - "tool_name": "", - "query": "", "supporting_sentence_indices": [0, 2] } ], @@ -300,7 +341,6 @@ 4. Each new belief needs a unique "tmp_id": n0, n1, n2, … in output order. 5. Each new decision needs a unique "tmp_id": d0, d1, d2, … in output order. 6. Empty beliefs / decisions / relations lists are OK when the content expresses none. -7. Every query-bearing tool call MUST produce one belief with exact "tool_name" and "query" properties. Code validates both fields against the source call. """ _HARD_CONSTRAINTS_SENTENCES = """\ @@ -313,7 +353,6 @@ 4. Each new belief needs a unique "tmp_id": n0, n1, n2, … in output order. 5. Each new decision needs a unique "tmp_id": d0, d1, d2, … in output order. 6. Empty beliefs / decisions / relations lists are OK when the sentences express none. -7. Every query-bearing tool call MUST produce one belief with exact "tool_name" and "query" properties. Code validates both fields against the source call. """ @@ -352,26 +391,18 @@ 'hedged guesses are "speculated".', ), "assistant": ( - "Extract coherent BELIEFS and DECISIONS from the ASSISTANT turn below. The content may " - "contain reasoning, tool-call syntax, and a final answer all together — read through ALL " - "of it and preserve the reasoning chain without creating tiny redundant nodes.", + "Extract coherent BELIEFS and DECISIONS from the ASSISTANT turn below. Preserve the " + "reasoning chain without creating tiny redundant nodes.", """\ ## Source role: ASSISTANT -The turn may mix internal reasoning, tool invocations, and the final answer. Extract: +Extract: - **Factual claims and intermediate conclusions** the assistant commits to (domain facts, numbers, diagnoses, derived states). - **Recommendations / advice** given to the user. - **Assessments** of the user's situation. - **Final decisions**: when the assistant gives a final answer, especially inside ``\\boxed{...}``, put it in ``decisions`` instead of ``beliefs``. -- **Tool calls**: extract every tool call as a belief — describe the assistant's information-seeking intent in natural language, capturing the tool name, the key parameters or constraints issued, and any hypothesis the call presupposes or commits to. -- **Query-bearing tool calls are mandatory**: for every ```` whose - ``arguments`` contains a string ``query`` (or ``q``), emit exactly one belief - for that call. Add ``tool_name`` and ``query`` properties to that belief and - copy both values exactly, character-for-character, from the tool call. Never - paraphrase, shorten, normalize, or omit either field. Do not add these - properties to beliefs derived from non-query content. - **Key reasoning steps that are falsifiable, reusable, or needed by later turns** — keep enough detail to reconstruct causal/dependency chains between user request, tool result, reasoning, and final answer. -Do NOT extract: pure procedure / planning filler ("Let me search next", "First I need to…") unless it encodes a substantive dependency; self-questions; raw tool-call JSON syntax / key names; or politeness. +Do NOT extract: pure procedure / planning filler ("Let me search next", "First I need to…") unless it encodes a substantive dependency; self-questions; or politeness. Write each belief in the third person ("The assistant…", "The user…") so it is self-contained. Resolve pronouns using the existing graph context when unambiguous.""", @@ -447,9 +478,10 @@ def build_update_prompt( _BELIEF_DEFINITION, _STANCE_DEFINITION, guidance + "\n", - _GRAPH_CONTEXT_BLOCK, - _FORWARD_EDGE_RULES, ] + if _has_existing_nodes(graph_nodes): + parts.append(_GRAPH_CONTEXT_BLOCK) + parts.append(_FORWARD_EDGE_RULES) if mode == "excerpt": parts.append(_HARD_CONSTRAINTS_EXCERPT) parts.append(_OUTPUT_FORMAT_EXCERPT) @@ -486,18 +518,14 @@ def build_update_prompt( { "beliefs": [ { - "tmp_id": "n0", "belief": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], - "tool_name": "", - "query": "", "supporting_excerpts": [""] } ], "decisions": [ { - "tmp_id": "d0", "decision": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], @@ -512,18 +540,14 @@ def build_update_prompt( { "beliefs": [ { - "tmp_id": "n0", "belief": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], - "tool_name": "", - "query": "", "supporting_sentence_indices": [0, 2] } ], "decisions": [ { - "tmp_id": "d0", "decision": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], @@ -533,16 +557,41 @@ def build_update_prompt( } """ +_OUTPUT_FORMAT_EXCERPT_USER_NODES = """\ +## Output (JSON only — no markdown fences, no commentary) +{ + "beliefs": [ + { + "belief": "", + "stance": "asserted | recalled | speculated | judged", + "entities": ["", "..."], + "supporting_excerpts": [""] + } + ] +} +""" + +_OUTPUT_FORMAT_SENTENCES_USER_NODES = """\ +## Output (JSON only — no markdown fences, no commentary) +{ + "beliefs": [ + { + "belief": "", + "stance": "asserted | recalled | speculated | judged", + "entities": ["", "..."], + "supporting_sentence_indices": [0, 2] + } + ] +} +""" + _HARD_CONSTRAINTS_EXCERPT_NODES = """\ ## Hard constraints 1. Preserve named entities, numbers, dates, quantities EXACTLY as written (incl. unusual punctuation like "!Kung"). 2. Do NOT add information not present in the content. No outside knowledge. 3. Every belief and decision MUST have at least one supporting excerpt — a VERBATIM, CONTIGUOUS substring copied character-for-character from the content. No excerpt → drop that node. -4. Each new belief needs a unique "tmp_id": n0, n1, n2, … in output order. -5. Each new decision needs a unique "tmp_id": d0, d1, d2, … in output order. -6. Empty beliefs / decisions lists are OK when the content expresses none. -7. Every query-bearing tool call MUST produce one belief with exact "tool_name" and "query" properties. Code validates both fields against the source call. +4. Empty beliefs / decisions lists are OK when the content expresses none. """ _HARD_CONSTRAINTS_SENTENCES_NODES = """\ @@ -552,10 +601,25 @@ def build_update_prompt( 3. Every belief and decision MUST list the indices of the COMPLETE sentence(s) that support it in "supporting_sentence_indices" (use the [k] indices shown). Evidence is always a whole sentence. If the whole group supports it, list all its indices. -4. Each new belief needs a unique "tmp_id": n0, n1, n2, … in output order. -5. Each new decision needs a unique "tmp_id": d0, d1, d2, … in output order. -6. Empty beliefs / decisions lists are OK when the sentences express none. -7. Every query-bearing tool call MUST produce one belief with exact "tool_name" and "query" properties. Code validates both fields against the source call. +4. Empty beliefs / decisions lists are OK when the sentences express none. +""" + +_HARD_CONSTRAINTS_EXCERPT_USER_NODES = """\ +## Hard constraints +Preserve names, numbers, dates, quantities, and unusual punctuation exactly. +Use only the current content; do not add outside knowledge. Every belief must +include at least one VERBATIM, CONTIGUOUS "supporting_excerpts" substring copied +from the content. Drop beliefs without an excerpt. An empty beliefs list is valid +when the input expresses none. +""" + +_HARD_CONSTRAINTS_SENTENCES_USER_NODES = """\ +## Hard constraints +Preserve names, numbers, dates, quantities, and unusual punctuation exactly. +Use only the current indexed sentences; do not add outside knowledge. For every +belief, return all COMPLETE sentence indices that directly support it in +"supporting_sentence_indices". Drop beliefs without supporting sentences. An +empty beliefs list is valid when the input expresses none. """ @@ -576,29 +640,62 @@ def build_node_extraction_prompt( return None task_line, guidance, _stance_hint = _GUIDANCE[key] - parts: list[str] = [ - "# Task", - task_line, - "\nYou maintain a belief graph INCREMENTALLY. From the CURRENT turn, output only the NEW " - "belief/decision nodes. Relations will be extracted in a separate step. " - "Existing nodes (shown below) must not be repeated.\n", - _BELIEF_DEFINITION, - _STANCE_DEFINITION, - guidance + "\n", - _NODE_GRAPH_CONTEXT_BLOCK, - ] + has_existing_nodes = _has_existing_nodes(graph_nodes) + if key == "user" and not has_existing_nodes: + task_intro = ( + "Extract coherent, self-contained beliefs from the current USER turn only. " + "Preserve distinct constraints without fragmenting one coherent request." + ) + else: + task_intro = task_line + + parts: list[str] = ["# Task", task_intro] + if key != "user" or has_existing_nodes: + node_kinds = "belief" if key == "user" else "belief/decision" + parts.append( + "\nMaintain the belief graph incrementally. Output only NEW " + f"{node_kinds} nodes from the CURRENT turn; relation extraction is separate. " + "Do not repeat existing nodes.\n" + ) + belief_definition = _BELIEF_DEFINITION + stance_definition = _STANCE_DEFINITION + role_guidance = guidance + "\n" + if key == "user": + belief_definition = _USER_BELIEF_DEFINITION + stance_definition = _USER_STANCE_DEFINITION + role_guidance = _USER_GUIDANCE + parts.extend([belief_definition, stance_definition, role_guidance]) + if has_existing_nodes: + parts.append(_NODE_GRAPH_CONTEXT_BLOCK) if mode == "excerpt": - parts.append(_HARD_CONSTRAINTS_EXCERPT_NODES) - parts.append(_OUTPUT_FORMAT_EXCERPT_NODES) + parts.append( + _HARD_CONSTRAINTS_EXCERPT_USER_NODES + if key == "user" + else _HARD_CONSTRAINTS_EXCERPT_NODES + ) + parts.append( + _OUTPUT_FORMAT_EXCERPT_USER_NODES + if key == "user" + else _OUTPUT_FORMAT_EXCERPT_NODES + ) parts.append(f"## Current turn content\n{CONTENT_PLACEHOLDER}\n") else: + if key != "user": + parts.append( + "## Sentence input\n" + "The current turn's content was split into COMPLETE sentences with stable indices [k]. " + "Reference them in supporting_sentence_indices; evidence is always a whole sentence.\n" + ) parts.append( - "## Sentence input\n" - "The current turn's content was split into COMPLETE sentences with stable indices [k]. " - "Reference them in supporting_sentence_indices; evidence is always a whole sentence.\n" + _HARD_CONSTRAINTS_SENTENCES_USER_NODES + if key == "user" + else _HARD_CONSTRAINTS_SENTENCES_NODES + ) + parts.append( + _OUTPUT_FORMAT_SENTENCES_USER_NODES + if key == "user" + else _OUTPUT_FORMAT_SENTENCES_NODES ) - parts.append(_HARD_CONSTRAINTS_SENTENCES_NODES) - parts.append(_OUTPUT_FORMAT_SENTENCES_NODES) parts.append(f"## Current turn sentences\n{SENTENCES_PLACEHOLDER}\n") prompt = "\n".join(parts) @@ -646,6 +743,12 @@ def build_assistant_tool_result_extraction_prompt( f"{assistant_sentences_block or ''}" ) ) + graph_context = "" + if _has_existing_nodes(graph_nodes): + graph_context = f"""## Existing belief nodes (read only) +{graph_nodes} + +""" return f"""# Task Extract belief/decision nodes from TWO ORDERED SOURCE LAYERS in one response: 1. the Assistant turn; @@ -664,9 +767,7 @@ def build_assistant_tool_result_extraction_prompt( Assistant's substantive reasoning, hypotheses, conclusions, and decisions, but do not copy raw tool-call syntax as semantic beliefs. -## Existing belief nodes (read only) -{graph_nodes or "[]"} - +{graph_context} {assistant_input} ## Tool Result items @@ -682,7 +783,6 @@ def build_assistant_tool_result_extraction_prompt( "assistant": {{ "beliefs": [ {{ - "tmp_id": "n0", "belief": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], @@ -691,7 +791,6 @@ def build_assistant_tool_result_extraction_prompt( ], "decisions": [ {{ - "tmp_id": "d0", "decision": "", "stance": "asserted | recalled | speculated | judged", "entities": ["", "..."], @@ -822,16 +921,21 @@ def build_relation_extraction_prompt( "into the graph. Your job is to identify meaningful semantic relations inside the " "candidate node window below: current-turn surviving new nodes plus at most one " "candidate prior turn, or current-turn nodes only.\n", - f"## Current turn ({role})\n" + CONTENT_PLACEHOLDER + "\n", - "## Candidate node window\n" - "The graph below is deliberately limited to the current turn's surviving new nodes " - "and one candidate prior turn's surviving nodes. It is not the full graph.\n\n" - "### Candidate nodes\n" + GRAPH_NODES_PLACEHOLDER + "\n\n" - "### Existing relations\n" + GRAPH_EDGES_PLACEHOLDER + "\n", - "## Nodes from this turn\n" + NEW_NODE_IDS_PLACEHOLDER + "\n", - _RELATION_EDGE_RULES, - _RELATION_OUTPUT_FORMAT, ] + if content.strip(): + parts.append(f"## Current turn ({role})\n" + CONTENT_PLACEHOLDER + "\n") + parts.extend( + [ + "## Candidate node window\n" + "The graph below is deliberately limited to the current turn's surviving new nodes " + "and one candidate prior turn's surviving nodes. It is not the full graph.\n\n" + "### Candidate nodes\n" + GRAPH_NODES_PLACEHOLDER + "\n\n" + "### Existing relations\n" + GRAPH_EDGES_PLACEHOLDER + "\n", + "## Nodes from this turn\n" + NEW_NODE_IDS_PLACEHOLDER + "\n", + _RELATION_EDGE_RULES, + _RELATION_OUTPUT_FORMAT, + ] + ) prompt = "\n".join(parts) prompt = prompt.replace(CONTENT_PLACEHOLDER, content or "") prompt = prompt.replace(GRAPH_NODES_PLACEHOLDER, graph_nodes or "[]") @@ -900,21 +1004,28 @@ def build_layered_relation_extraction_prompt( parts: list[str] = [ "# Task", "Link the current Assistant belief nodes to the most relevant prior layer.", - f"## Current Assistant reasoning ({role})\n" + CONTENT_PLACEHOLDER + "\n", - "## Candidate previous layers\n" - "Layer 1 is the nearest non-empty Graph turn; larger numbers are older. " - "Layer membership is authoritative.\n" + candidate_layers + "\n", - "## Candidate nodes\n" - + GRAPH_NODES_PLACEHOLDER - + "\n\n## Existing relations\n" - + GRAPH_EDGES_PLACEHOLDER - + "\n", - "## Current-turn node ids\n" + NEW_NODE_IDS_PLACEHOLDER + "\n", - _LAYERED_RELATION_EDGE_RULES, - "## Layer selection\n" - "Compare all candidates in one pass. Cross-turn relations may use ZERO OR " - "ONE previous layer, never a mixture. Select null when none is meaningful.\n", ] + if content.strip(): + parts.append( + f"## Current Assistant reasoning ({role})\n" + CONTENT_PLACEHOLDER + "\n" + ) + parts.extend( + [ + "## Candidate previous layers\n" + "Layer 1 is the nearest non-empty Graph turn; larger numbers are older. " + "Layer membership is authoritative.\n" + candidate_layers + "\n", + "## Candidate nodes\n" + + GRAPH_NODES_PLACEHOLDER + + "\n\n## Existing relations\n" + + GRAPH_EDGES_PLACEHOLDER + + "\n", + "## Current-turn node ids\n" + NEW_NODE_IDS_PLACEHOLDER + "\n", + _LAYERED_RELATION_EDGE_RULES, + "## Layer selection\n" + "Compare all candidates in one pass. Cross-turn relations may use ZERO OR " + "ONE previous layer, never a mixture. Select null when none is meaningful.\n", + ] + ) if validation_feedback: parts.append( "## Validation feedback from the previous attempt\n" diff --git a/bcg/construct/unified/stream.py b/bcg/construct/unified/stream.py index 155ae240..42b12298 100644 --- a/bcg/construct/unified/stream.py +++ b/bcg/construct/unified/stream.py @@ -36,7 +36,7 @@ from typing import Any from .._shared.roles import normalize_role -from .._shared.tool_queries import extract_tool_calls, strip_valid_tool_calls +from .._shared.tool_queries import extract_tool_calls from .._shared.tool_results import extract_tool_results from .._shared.writers import ArtifactWriter, EventRecorder from ..hybrid.named_entities import normalize_entity_config @@ -61,6 +61,7 @@ extract_nodes, extract_relations, extract_rule_tool_result_nodes, + format_extraction_nodes, format_graph_edges, format_graph_nodes, format_relation_nodes, @@ -91,11 +92,11 @@ class StreamOptions: context_chars: int = 100000 # existing-nodes context budget # Limit node-extraction history to the latest N non-empty Graph turns before # applying ``context_chars``. Zero preserves the character-budget-only path. - extraction_history_turns: int = 0 + extraction_history_turns: int = 2 # Maximum number of non-empty historical turn windows considered while # looking for a current-to-prior relation. Assistant turns bundle these # windows into one request; other roles inspect them sequentially. - max_previous_windows: int = 4 + max_previous_windows: int = 3 # None selects the model-aware default (GPT-5.6-Luna -> none; otherwise # medium). A configured value is forwarded to every graph-model call. reasoning_effort: str | None = None @@ -309,7 +310,7 @@ def _node_extraction_history(self) -> list[dict[str, Any]]: ] def _formatted_node_extraction_history(self) -> str: - return format_graph_nodes( + return format_extraction_nodes( self._node_extraction_history(), char_budget=self.options.context_chars, ) @@ -1572,7 +1573,6 @@ def _extract_relations_for_layered_edge_window( displayed_layers.append( { "layer": layer_number, - "trajectory_index": trajectory_index, "node_ids": sorted(retained_ids), } ) @@ -1632,9 +1632,10 @@ def cross_layer(relation: dict[str, Any]) -> int | None: self.client, self.model, role=role, - # Tool calls already have deterministic belief nodes. Keep only - # Thinking/plain reasoning as semantic evidence for relations. - content=strip_valid_tool_calls(content), + # Current Assistant semantics are already represented by the + # surviving belief nodes. Avoid duplicating the raw reasoning in + # every relation request. + content="", graph_nodes_str=graph_nodes_post, graph_edges_str=graph_edges_post, new_node_ids=displayed_new_ids, @@ -1815,9 +1816,13 @@ def _extract_relations_for_edge_window( this is the backward-search stop condition. """ edge_window_ids = surviving_new_ids | previous_node_ids - graph_nodes_post = format_graph_nodes( - [node for node in active_nodes if node.get("id") in edge_window_ids], - char_budget=context_chars, + window_nodes = [ + node for node in active_nodes if node.get("id") in edge_window_ids + ] + graph_nodes_post = ( + format_relation_nodes(window_nodes, char_budget=context_chars) + if normalize_role(role) == "tool" + else format_graph_nodes(window_nodes, char_budget=context_chars) ) graph_edges_post = format_graph_edges( self.graph.relations, keep_ids=edge_window_ids @@ -1833,7 +1838,11 @@ def _extract_relations_for_edge_window( self.client, self.model, role=role, - content=content, + # Tool Result semantics are already represented by the extracted + # current-turn nodes. The deterministic provenance pass separately + # pairs each result with its exact Tool Call, so the raw result text + # is redundant in this model-based relation request. + content="" if normalize_role(role) == "tool" else content, graph_nodes_str=graph_nodes_post, graph_edges_str=graph_edges_post, new_node_ids=surviving_new_ids, diff --git a/bcg/model_config.example.json b/bcg/model_config.example.json index 2dbb0149..a7a39c05 100644 --- a/bcg/model_config.example.json +++ b/bcg/model_config.example.json @@ -90,11 +90,12 @@ "enable_thinking": false, "fail_on_error": true, "search_previous_turns": true, - "max_previous_windows": 4 + "max_previous_windows": 3 }, "runtime": { "evidence_mode": "chunk", "context_chars": 12000, + "extraction_history_turns": 2, "min_content_len": 0 }, "incremental_merge": { diff --git a/tests/test_belief_graph.py b/tests/test_belief_graph.py index 267a66d7..34d7015c 100644 --- a/tests/test_belief_graph.py +++ b/tests/test_belief_graph.py @@ -40,6 +40,7 @@ extract_compact_tool_result_nodes_batch, extract_nodes, extract_rule_tool_result_nodes, + format_extraction_nodes, format_graph_nodes, format_relation_nodes, ) @@ -633,6 +634,27 @@ def test_unified_relation_nodes_include_only_id_and_content() -> None: ) assert json.loads(rendered) == [{"id": 7, "content": "A compact semantic fact."}] + + +def test_unified_extraction_history_includes_only_content() -> None: + rendered = format_extraction_nodes( + [ + { + "id": 7, + "node_type": "belief", + "belief": "A prior semantic fact.", + "role": "assistant", + "stance": "speculated", + "confidence": 0.6, + "entities": ["fact"], + "event_time": "2026-08-27T00:00:00Z", + "source": {"turn_index": 3}, + } + ], + char_budget=None, + ) + + assert json.loads(rendered) == [{"content": "A prior semantic fact."}] assert "stance" not in rendered assert "confidence" not in rendered assert "entities" not in rendered @@ -719,10 +741,11 @@ def fake_layered(*args: Any, **kwargs: Any) -> dict[str, Any]: 2, 3, ] - assert "Current reasoning." in layered_calls[0]["content"] - assert "Visible reasoning." in layered_calls[0]["content"] - assert "" not in layered_calls[0]["content"] - assert "secret query" not in layered_calls[0]["content"] + assert all( + "trajectory_index" not in layer + for layer in layered_calls[0]["candidate_layers"] + ) + assert layered_calls[0]["content"] == "" assert '"stance"' not in layered_calls[0]["graph_nodes_str"] assert '"entities"' not in layered_calls[0]["graph_nodes_str"] assert event["edge_attempts"][0]["validation_passed"] is True @@ -845,6 +868,82 @@ def test_unified_node_extraction_prompt_omits_edges() -> None: assert "The latest node." in prompt assert "Existing relations" not in prompt assert '"from": 1' not in prompt + assert '"tool_name"' not in prompt + assert '"query"' not in prompt + assert "query-bearing tool call" not in prompt + + +def test_unified_node_extraction_prompt_omits_empty_context_and_tmp_ids() -> None: + prompt = build_node_extraction_prompt( + "user", + mode="sentences", + sentences_block="[0] The user asks a question.", + graph_nodes="[]", + ) + + assert prompt is not None + assert "Existing belief nodes" not in prompt + assert '"tmp_id"' not in prompt + assert '"decisions"' not in prompt + assert "decision" not in prompt.lower() + assert '"stance"' in prompt + assert '"entities"' in prompt + assert '"supporting_sentence_indices"' in prompt + assert "confidence is assigned downstream" not in prompt + assert "event metadata is assigned" not in prompt + assert "You maintain a belief graph INCREMENTALLY" not in prompt + assert "independently searchable numbered clues" in prompt + assert "task-defining" in prompt + assert "qualified roles" in prompt + + +def test_unified_node_extraction_assigns_code_owned_tmp_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_call(*args: Any, **kwargs: Any) -> str: + del args, kwargs + return json.dumps( + { + "beliefs": [ + { + "tmp_id": "model-chosen-id", + "belief": "First belief.", + "stance": "asserted", + "entities": ["First"], + "supporting_sentence_indices": [0], + }, + { + "tmp_id": "model-chosen-id", + "belief": "Second belief.", + "stance": "judged", + "entities": ["Second"], + "supporting_sentence_indices": [1], + }, + ], + "decisions": [ + { + "tmp_id": "another-model-id", + "decision": "Final decision.", + "stance": "judged", + "entities": ["Final"], + "supporting_sentence_indices": [1], + } + ], + } + ) + + monkeypatch.setattr("bcg.construct.unified.extract.llm.call_model", fake_call) + result = extract_nodes( + object(), + "graph-model", + role="assistant", + mode="sentences", + sentences=["First belief.", "Second belief and final decision."], + ) + + assert [node["tmp_id"] for node in result["nodes"]] == ["n0", "n1", "d2"] + assert result["nodes"][1]["stance"] == "judged" + assert result["nodes"][1]["entities"] == ["Second"] def test_stream_node_extraction_respects_context_chars( @@ -894,7 +993,7 @@ def fake_extract_nodes(*args: Any, **kwargs: Any) -> dict[str, Any]: ) builder.ingest_turn("user", "oldest " + "a" * 260) builder.ingest_turn("user", "newest " + "b" * 260) - expected = format_graph_nodes(builder.graph.active(), char_budget=450) + expected = format_extraction_nodes(builder.graph.active(), char_budget=450) builder.ingest_turn("user", "current") assert extraction_contexts[-1] == expected @@ -1065,6 +1164,7 @@ def test_grouped_parallel_results_pair_exact_calls_then_model_link_thinking( monkeypatch: pytest.MonkeyPatch, ) -> None: relation_windows: list[str] = [] + relation_contents: list[str] = [] def fake_extract_nodes(*args: Any, **kwargs: Any) -> dict[str, Any]: del args @@ -1127,6 +1227,7 @@ def fake_extract_relations(*args: Any, **kwargs: Any) -> dict[str, Any]: del args graph_nodes = str(kwargs["graph_nodes_str"]) relation_windows.append(graph_nodes) + relation_contents.append(str(kwargs["content"])) if thinking_id is not None and "Alpha result" in graph_nodes: current_ids = sorted(kwargs["new_node_ids"]) return { @@ -1203,6 +1304,18 @@ def fake_extract_relations(*args: Any, **kwargs: Any) -> dict[str, Any]: for relation in builder.graph.relations ) assert len(relation_windows) == 1 + assert json.loads(relation_windows[0]) == [ + {"id": node["id"], "content": node["belief"]} + for node in [ + next( + graph_node + for graph_node in builder.graph.active() + if graph_node["id"] == thinking_id + ), + *results, + ] + ] + assert relation_contents[0] == "" assert "The assistant is comparing alpha and beta." in relation_windows[0] assert "using web_search" not in relation_windows[0] assert event["edge_attempts"][0]["pairing_strategy"] == "tool_call_id" @@ -1614,7 +1727,8 @@ def no_relations(*args: Any, **kwargs: Any) -> dict[str, Any]: assert len(extraction_prompts) == 2 assistant_prompt, tool_prompt = extraction_prompts - assert "## Existing belief nodes" in assistant_prompt + assert "## Existing belief nodes" not in assistant_prompt + assert '"tmp_id"' not in assistant_prompt assert "Alpha may be relevant." in assistant_prompt assert "alpha query" not in assistant_prompt assert "Items:" in tool_prompt diff --git a/tests/test_env.py b/tests/test_env.py index 3f35bae4..b406e01c 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -195,7 +195,7 @@ def test_hybrid_configs_resolve_all_credentials_from_environment( assert embedding["api_key"] == "embedding-secret" assert extractor["api_key"] == "local-secret" assert edge["api_key"] == "local-secret" - assert edge["max_previous_windows"] == 4 + assert edge["max_previous_windows"] == 3 def test_hybrid_edge_config_accepts_bounded_historical_window_override(