From f68a7b5e218dd3764f9becd62a3b31dae0f9b904 Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Mon, 17 Nov 2025 16:33:42 +0000 Subject: [PATCH 01/14] migrating deep mindmaps --- examples/grounded_mindmaps.py | 109 +++ src/bigdata_research_tools/llm/base.py | 4 +- src/bigdata_research_tools/llm/openai.py | 12 +- .../{tree.py => mindmap/mindmap.py} | 78 +- .../mindmap/mindmap_generator.py | 719 ++++++++++++++++++ .../mindmap/mindmap_utils.py | 121 +++ .../search/query_builder.py | 2 +- src/bigdata_research_tools/search/search.py | 14 +- .../workflows/risk_analyzer.py | 14 +- .../workflows/thematic_screener.py | 2 +- 10 files changed, 1030 insertions(+), 45 deletions(-) create mode 100644 examples/grounded_mindmaps.py rename src/bigdata_research_tools/{tree.py => mindmap/mindmap.py} (86%) create mode 100644 src/bigdata_research_tools/mindmap/mindmap_generator.py create mode 100644 src/bigdata_research_tools/mindmap/mindmap_utils.py diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py new file mode 100644 index 0000000..f032b8d --- /dev/null +++ b/examples/grounded_mindmaps.py @@ -0,0 +1,109 @@ +import logging + +from bigdata_client import Bigdata +from bigdata_client.models.search import DocumentType +from dotenv import load_dotenv +from traitlets import Any + +from bigdata_research_tools.mindmap.mindmap import MindMap +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator + +# Load environment variables for authentication +print(f"Environment variables loaded: {load_dotenv()}") + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +def test_one_shot_mindmap(main_theme, focus, map_type, instructions, llm_base_config: str = "openai::gpt-4o-mini") -> MindMap: + """Test one-shot mind map generation with base LLM.""" + logger.info("=" * 60) + logger.info("TEST 1: One-Shot Mind Map Generation with Base LLM") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config,) + mindmap = mindmap_generator.generate_one_shot( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type = map_type, + allow_grounding=False, +) + logger.info("Results: %s", mindmap['mindmap_text']) + return mindmap["mindmap_json"] + + +def test_refined_mindmap(main_theme, focus, map_type, instructions, base_mindmap: str, llm_base_config: str = "openai::o3-mini") -> MindMap: + """Test refined mindmap generation with reasoning LLM sent in the base config.""" + logger.info("=" * 60) + logger.info("TEST 2: Refined MindMap Generation with Reasoning LLM in Base Config") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config,) + mindmap = mindmap_generator.generate_refined(focus = focus, + main_theme = main_theme, + initial_mindmap = base_mindmap, + grounding_method = "tool_call", + output_dir = "./refined_mindmaps", + filename = "refined_mindmap.json", + map_type = map_type, + instructions = instructions, + ) + logger.info("Results: %s", mindmap['mindmap_text']) + +def test_refined_mindmap2(main_theme, focus, map_type, instructions, base_mindmap: str, llm_base_config: str | None = None, llm_reasoning_config: str = "openai::o3-mini") -> MindMap: + """Test refined mindmap generation with reasoning LLM sent in the reasoning config.""" + logger.info("=" * 60) + logger.info("TEST 3: Refined MindMap Generation with Reasoning LLM in Reasoning Config") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config) + mindmap = mindmap_generator.generate_refined(focus = focus, + main_theme = main_theme, + initial_mindmap = base_mindmap, + grounding_method = "tool_call", + output_dir = "./refined_mindmaps", + filename = "refined_mindmap.json", + map_type = map_type, + instructions = instructions, + ) + logger.info("Results: %s", mindmap['mindmap_text']) + +def test_dynamic_mindmap(main_theme, focus, map_type, instructions, llm_base_config: str = "openai::gpt-4o-mini", llm_reasoning_config: str = "openai::o3-mini") -> MindMap: + """Test dynamic mindmap generation with two LLMs.""" + logger.info("=" * 60) + logger.info("TEST 4: Dynamic MindMap Generation with Two LLMs") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config) + mindmap = mindmap_generator.generate_dynamic( + instructions = instructions, + focus = focus, + main_theme = main_theme, + month_intervals = [["2025-10-01", "2025-10-31"], ["2025-11-01", "2025-11-30"], ["2025-12-01", "2025-12-31"]], + month_names = ['October_2025', 'November_2025', 'December_2025'],) + logger.info("Results: %s", mindmap['base_mindmap']) + logger.info("Results: %s", mindmap['October_2025']) + logger.info("") + +def main(MAIN_THEME = "Political Change in Japan.", + INSTRUCTIONS = 'Create a mindmap according to a given risk scenario. Map by risk type for any industry and assess short term impact only.', + FOCUS = "Provide a detailed taxonomy of risks related to changes in the Japanese political landscape. Evaluate how the resignation of the Prime Minister and the pre-election of Sanae Takaichi will affect companies, their strategy and operations. Take into consideration their increased conservative stance on immigration, energy, and trade. Add any other risk areas that may arise from these political changes. The mind map should be as comprehensive as possible and cover all major risk areas.", + map_type = 'risk'): + """Run all tests.""" + logger.info("Testing Grounded MindMap Generation") + logger.info("=" * 60) + + try: + # base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") + # test_refined_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") + # test_refined_mindmap2(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") + test_dynamic_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini", llm_reasoning_config="openai::o3-mini") + + logger.info("=" * 60) + logger.info("All tests completed successfully") + + except Exception as e: + logger.error("Error during testing: %s", e) + raise + +if __name__ == "__main__": + main() diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 8296733..3dc72fe 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -245,7 +245,6 @@ def get_tools_response( self, chat_history: list[dict[str, str]], tools: list[dict[str, str]], - temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -332,7 +331,6 @@ def get_tools_response( self, chat_history: list[dict[str, str]], tools: list[dict[str, str]], - temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -353,7 +351,7 @@ def get_tools_response( - text (str): The text content of the message, if any. """ return self.provider.get_tools_response( - chat_history, tools, temperature, **kwargs + chat_history, tools, **kwargs ) diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 57152bd..6c4670c 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -59,7 +59,6 @@ async def get_tools_response( self, chat_history: list[dict[str, str]], tools: list[dict[str, str]], - temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -85,7 +84,6 @@ async def get_tools_response( messages=chat_history, model=self.model, tools=tools, - temperature=temperature, **kwargs, ) message = response.choices[0].message @@ -170,9 +168,8 @@ def get_tools_response( self, chat_history: list[dict[str, str]], tools: list[dict[str, str]], - temperature: float = 0, **kwargs, - ) -> dict[str, list[dict] | str]: + ) -> dict[list, list, list[dict] | str, dict]: """ Get the response from an LLM model from OpenAI with tools. Args: @@ -196,19 +193,22 @@ def get_tools_response( messages=chat_history, model=self.model, tools=tools, - temperature=temperature, **kwargs, ) message = response.choices[0].message output = { + "id" : [], "func_names": [], "arguments": [], "text": message.content, - } + "tool_calls": {}} + if function_calls := message.tool_calls if message.tool_calls else None: output = { + "id" : [f.id for f in function_calls], "func_names": [f.function.name for f in function_calls], "arguments": [loads(f.function.arguments) for f in function_calls], + "tool_calls": response.model_dump().get("choices", [])[0].get("message", {}).get("tool_calls", []) } return output diff --git a/src/bigdata_research_tools/tree.py b/src/bigdata_research_tools/mindmap/mindmap.py similarity index 86% rename from src/bigdata_research_tools/tree.py rename to src/bigdata_research_tools/mindmap/mindmap.py index 018451e..c047dc4 100644 --- a/src/bigdata_research_tools/tree.py +++ b/src/bigdata_research_tools/mindmap/mindmap.py @@ -30,7 +30,7 @@ @dataclass -class SemanticTree: +class MindMap: """ A hierarchical tree structure where each node represents a semantically meaningful unit, or node, that guide the analyst's research process. @@ -44,36 +44,36 @@ class SemanticTree: summary (str): A brief explanation of the node's relevance. For the root node, this describes the overall relevance of the tree; for sub-nodes, it explains their connection to the parent node. - children (list[SemanticTree] | None): A list of child nodes representing sub-units. + children (list[MindMap] | None): A list of child nodes representing sub-units. keywords (list[str] | None): A list of keywords summarizing the current node. """ label: str node: int summary: str = "" - children: list["SemanticTree"] = field(default_factory=list) + children: list["MindMap"] = field(default_factory=list) keywords: list[str] = field(default_factory=list) def __str__(self) -> str: return self.as_string() @staticmethod - def from_dict(tree_dict: dict) -> "SemanticTree": + def from_dict(tree_dict: dict) -> "MindMap": """ - Create a SemanticTree object from a dictionary. + Create a MindMap object from a dictionary. Args: - tree_dict (dict): A dictionary representing the SemanticTree structure. + tree_dict (dict): A dictionary representing the MindMap structure. Returns: - SemanticTree: The SemanticTree object generated from the dictionary. + MindMap: The MindMap object generated from the dictionary. """ # Handle case sensitivity in keys tree_dict = dict_keys_to_lowercase(tree_dict) - tree = SemanticTree(**tree_dict) # ty: ignore[missing-argument] + tree = MindMap(**tree_dict) # ty: ignore[missing-argument] tree.children = [ - SemanticTree.from_dict(child) for child in tree_dict.get("children", []) + MindMap.from_dict(child) for child in tree_dict.get("children", []) ] return tree @@ -110,7 +110,7 @@ def get_label_summaries(self) -> dict[str, str]: Extract the label summaries from the tree. Returns: - dict[str, str]: Dictionary with all the labels of the SemanticTree as keys and their associated summaries as values. + dict[str, str]: Dictionary with all the labels of the MindMap as keys and their associated summaries as values. """ label_summary = {self.label: self.summary} for child in self.children: @@ -119,7 +119,7 @@ def get_label_summaries(self) -> dict[str, str]: def get_summaries(self) -> list[str]: """ - Extract the node summaries from a SemanticTree. + Extract the node summaries from a MindMap. Returns: list[str]: List of all 'summary' values in the tree, including its children. @@ -134,7 +134,7 @@ def get_terminal_label_summaries(self) -> dict[str, str]: Extract the items (labels, summaries) from terminal nodes of the tree. Returns: - dict[str, str]: Dictionary with the labels of the SemanticTree as keys and + dict[str, str]: Dictionary with the labels of the MindMap as keys and their associated summaries as values, only using terminal nodes. """ label_summary = {} @@ -214,7 +214,7 @@ def _visualize_graphviz(self) -> graphviz.Digraph: splines="curved", ) - def add_nodes(node: SemanticTree): + def add_nodes(node: MindMap): # Determine if the node is a terminal (leaf) node is_terminal = not node.children @@ -272,7 +272,7 @@ def _visualize_plotly(self) -> None: "please install `bigdata_research_tools[plotly]` to enable them." ) - def extract_labels(node: SemanticTree, parent_label=""): + def extract_labels(node: MindMap, parent_label=""): labels.append(node.label) parents.append(parent_label) for child in node.children: @@ -330,14 +330,44 @@ def save_json(self, filepath: str, **kwargs) -> None: with open(filepath, "w", encoding="utf-8") as f: json.dump(self._to_dict(), f, ensure_ascii=False, indent=2, **kwargs) + def to_rows(self, parent_label=None): + """ + Flatten tree to rows for DataFrame: each row is (Parent, Label, Node, Summary) + """ + rows = [] + rows.append({ + "Parent": parent_label, + "Label": self.label, + "Node": self.node, + "Summary": self.summary + }) + for child in self.children: + rows.extend(child.to_rows(parent_label=self.label)) + return rows + + def to_dataframe(self, leaves_only=False): + import pandas as pd + rows = self.to_rows(parent_label=None) + # Exclude rows where Parent is None or Parent == self.label (root node) + filtered = [row for row in rows if row["Parent"] not in (None, self.label)] + if leaves_only: + # Only keep rows that are leaves (i.e., have no children) + leaf_labels = {row["Label"] for row in filtered} + filtered = [row for row in filtered if row["Label"] not in {r["Parent"] for r in filtered}] + return pd.DataFrame(filtered) + + def to_json(self): + + return json.dumps(self._to_dict(), indent=2) + def generate_theme_tree( main_theme: str, focus: str = "", llm_model_config: LLMConfig | dict | str = "openai::gpt-4o-mini", -) -> SemanticTree: +) -> MindMap: """ - Generate a `SemanticTree` class from a main theme and focus. + Generate a `MindMap` class from a main theme and focus. Args: main_theme (str): The primary theme to analyze. @@ -354,7 +384,7 @@ def generate_theme_tree( - `seed` (int) Returns: - SemanticTree: The generated theme tree. + MindMap: The generated theme tree. """ if isinstance(llm_model_config, dict): llm_model_config = LLMConfig(**llm_model_config) @@ -380,7 +410,7 @@ def generate_theme_tree( tree_str = repair_json(tree_str) tree_dict = ast.literal_eval(tree_str) - return SemanticTree.from_dict(tree_dict) + return MindMap.from_dict(tree_dict) def dict_keys_to_lowercase(d: dict[str, Any]) -> dict[str, Any]: @@ -404,10 +434,10 @@ def dict_keys_to_lowercase(d: dict[str, Any]) -> dict[str, Any]: def stringify_label_summaries(label_summaries: dict[str, str]) -> list[str]: """ - Convert the label summaries of a SemanticTree into a list of strings. + Convert the label summaries of a MindMap into a list of strings. Args: - label_summaries (dict[str, str]): A dictionary of label summaries of SemanticTree. + label_summaries (dict[str, str]): A dictionary of label summaries of MindMap. Expected format: {label: summary}. Returns: List[str]: A list of strings, each one containing a label and its summary, i.e. @@ -420,9 +450,9 @@ def generate_risk_tree( main_theme: str, focus: str = "", llm_model_config: LLMConfig | dict | str = "openai::gpt-4o-mini", -) -> SemanticTree: +) -> MindMap: """ - Generate a `SemanticTree` class from a main theme and analyst focus. + Generate a `MindMap` class from a main theme and analyst focus. Args: main_theme (str): The primary theme to analyze. @@ -440,7 +470,7 @@ def generate_risk_tree( - `seed` (int) Returns: - SemanticTree: The generated theme tree. + MindMap: The generated theme tree. """ if isinstance(llm_model_config, dict): llm_model_config = LLMConfig(**llm_model_config) @@ -465,7 +495,7 @@ def generate_risk_tree( tree_dict = ast.literal_eval(tree_str) - return SemanticTree.from_dict(tree_dict) + return MindMap.from_dict(tree_dict) def get_default_tree_config(llm_model: str) -> LLMConfig: diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py new file mode 100644 index 0000000..a53480c --- /dev/null +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -0,0 +1,719 @@ +from typing import Any, List, Dict, Optional, Tuple +from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.llm import LLMEngine + +from bigdata_research_tools.search.query_builder import build_batched_query +# from bigdata_research_tools.search.query_builder import ( +# EntitiesToSearch, +# build_batched_query, +# create_date_ranges, +# ) +# cannot use query builder because it is to error-prone to build EntitiesToSearch based on the LLM output +from bigdata_research_tools.search.search import run_search +from bigdata_research_tools.client import bigdata_connection +from bigdata_client.query import ( + Any, + Keyword, + Similarity, +) + +from bigdata_research_tools.mindmap.mindmap_utils import format_mindmap_to_dataframe, save_results_to_file, load_results_from_file, prompts_dict +import os +import json +import re +import json +import ast +from concurrent.futures import ThreadPoolExecutor, as_completed +from tqdm import tqdm +from bigdata_research_tools.mindmap.mindmap import MindMap, get_default_tree_config +from logging import Logger, getLogger +from bigdata_client.models.search import DocumentType, SortBy +from bigdata_client.daterange import RollingDateRange, AbsoluteDateRange +logger: Logger = getLogger(__name__) + +bigdata_tool_description = [{ + "type": "function", + "function": { + "name": "bigdata_search", + "description": "Run a semantic similarity search on news content using Bigdata API.", + "parameters": { + "type": "object", + "properties": { + "search_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of strings containing various detailed sentences to search in News documents.", + }, + "entities_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of entities (People, Places or Organizations) to focus the search on. They will be added as search context with an OR logic.", + }, + "keywords_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of keywords (one or two words defining topics or concepts) to focus the search on. They will be added as search context with an OR logic.", + } + }, + "required": ["search_list", "entities_list", "keywords_list"] + } + } + }] + +class MindMapGenerator: + """ + Core orchestrator for generating, refining, and dynamically evolving mind maps using LLMs and Bigdata search. + + Features: + - One-shot mind map generation (optionally grounded in search results) + - Refined mind map generation (LLM proposes searches to enhance an initial mind map) + - Dynamic mind map evolution over time intervals (each step refines previous map with new search context) + """ + + def __init__(self, + llm_model_config_base: LLMConfig | dict | str = "openai::gpt-4o-mini", + llm_model_config_reasoning: Optional[LLMConfig | dict | str] = None, + ): + """ + Args: + llm_client: Handles LLM chat and tool-calling. + """ + self.bigdata_connection = bigdata_connection() + + llm_model_config_reasoning = llm_model_config_reasoning if llm_model_config_reasoning else llm_model_config_base + + if isinstance(llm_model_config_base, dict): + self.llm_model_config_base = LLMConfig(**llm_model_config_base) + elif isinstance(llm_model_config_base, str): + self.llm_model_config_base = get_default_tree_config(llm_model_config_base) + + if isinstance(llm_model_config_reasoning, dict): + self.llm_model_config_reasoning = LLMConfig(**llm_model_config_reasoning) + elif isinstance(llm_model_config_reasoning, str): + self.llm_model_config_reasoning = get_default_tree_config(llm_model_config_reasoning) + + print(self.llm_model_config_base) + self.llm_base = LLMEngine(model=self.llm_model_config_base.model, **self.llm_model_config_base.connection_config) + print(self.llm_model_config_reasoning) + self.llm_reasoning = LLMEngine(model=self.llm_model_config_reasoning.model, **self.llm_model_config_reasoning.connection_config) + + def _parse_llm_to_themetree(self, mindmap_text: str) -> MindMap: + """ + Parse LLM output (expected to be a valid JSON object) into a MindMap. + Strictly enforce JSON/dict structure, required fields, and allowed keys. If parsing or validation fails, raises an error with details. + """ + import collections.abc + text = mindmap_text.strip() + # Remove code block markers and language tags (minimal cleaning) + text = re.sub(r'^```[a-zA-Z]*\s*', '', text) + text = re.sub(r'```$', '', text) + # Remove accidental language tags at the start (e.g., "json\n{") + text = re.sub(r'^[a-zA-Z]+\s*\n*{', '{', text) + # Remove any prefix before the first { or [ + text = re.sub(r'^[^({\[]*({|\[)', r'\1', text, flags=re.DOTALL) + # Try JSON, then ast.literal_eval + try: + tree_dict = json.loads(text) + except Exception: + try: + tree_dict = ast.literal_eval(text) + except Exception as e: + raise ValueError(f"Failed to parse LLM output as JSON or Python dict.\nRaw output:\n{mindmap_text}\nCLEANED OUTPUT:\n{text}\nError: {e}") + + # --- Strict validation of required fields and allowed keys --- + allowed_keys = {"label", "node", "summary", "children"} + def validate_node(node, path="root"): + if not isinstance(node, dict): + raise ValueError(f"Node at {path} is not a dict: {node}") + # Check for illegal keys + illegal_keys = set(node.keys()) - allowed_keys + if illegal_keys: + raise ValueError(f"Illegal key(s) {illegal_keys} at {path}. Node: {node}") + # Check for required fields + for key in allowed_keys: + if key not in node or node[key] is None: + raise ValueError(f"Missing or null required field '{key}' at {path}. Node: {node}") + if not isinstance(node["children"], list): + raise ValueError(f"'children' field at {path} is not a list. Node: {node}") + for idx, child in enumerate(node["children"]): + validate_node(child, path=f"{path} -> children[{idx}]") + + # Lowercase keys for robustness + def dict_keys_to_lowercase(d): + if isinstance(d, dict): + return {k.lower(): dict_keys_to_lowercase(v) for k, v in d.items()} + elif isinstance(d, list): + return [dict_keys_to_lowercase(i) for i in d] + else: + return d + tree_dict = dict_keys_to_lowercase(tree_dict) + try: + validate_node(tree_dict) + except Exception as e: + raise ValueError(f"Mind map structure validation failed: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}") + try: + theme_tree = MindMap.from_dict(tree_dict) + except Exception as e: + raise ValueError(f"Failed to build ThemeTree from dict: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}") + return theme_tree + + def _themetree_to_dataframe(self, theme_tree: MindMap): + """ + Convert a ThemeTree object to a pandas DataFrame. + """ + try: + df = theme_tree.to_dataframe() + except Exception as e: + raise ValueError(f"Failed to convert ThemeTree to DataFrame: {e}\nThemeTree:\n{theme_tree}") + return df + + def compose_base_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str]) -> list: + # Explicit, step-by-step prompt (robust, as in working repo, minus Keywords) + enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + messages = [ + {"role": "system", "content": f"{instructions} {focus}\n{enforce_structure}"}, + {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)} + ] + return messages + + def compose_tool_call_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], initial_mindmap: Optional[str]) -> list: + enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." + if initial_mindmap: + + tool_prompt+=f"Starting from the following mind map:\n{initial_mindmap}" + + tool_prompt+=f"\nReturn a list of searches you would like to perform to enhance it.\n{enforce_structure}" + + messages = [ + {"role": "system", "content": tool_prompt}, + {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)} + ] + + return messages + + def send_tool_call(self, messages: list, llm_client:LLMEngine, llm_kwargs: dict) -> list: + + llm_kwargs.update({"tool_choice": {"type": "function", "function": {"name": "bigdata_search"}}}) + + response_dict = llm_client.get_tools_response( + messages,tools=bigdata_tool_description, **llm_kwargs) + + try: + if response_dict["tool_calls"] is not None: + + tool_call_id = response_dict["id"][0] + arguments = response_dict["arguments"][0] + search_list = arguments.get("search_list", []) + entities_list = arguments.get("entities_list", []) + keywords_list = arguments.get("keywords_list", []) + return tool_call_id, response_dict["tool_calls"], search_list, entities_list, keywords_list + else: + print("No tool call found in the response.") + + return None, None, response_dict["text"], None, None + except Exception as e: + raise RuntimeError(f"Failed to parse OpenAI tool call response: {e}") + + def compose_final_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], tool_calls, tool_call_id, context) -> list: + enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + + final_message = [ + {"role": "system", "content": f"{instructions} {focus}. IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant. \n{enforce_structure}"}, + {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)}, + { + "role": "assistant", + "content": None, + "tool_calls": tool_calls + }, + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": context + } + ] + + return final_message + + def compose_refinement_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], initial_mindmap: str, context: str, tool_calls, tool_call_id) -> list: + + enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + + refine_prompt = ( + f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus} " + "Based on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text." + "IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant." + f"{enforce_structure}." + + ) + refinement_messages = [ + {"role": "system", "content": refine_prompt}, + {"role": "user", "content": initial_mindmap}, + { + "role": "assistant", + "content": None, + "tool_calls": tool_calls + }, + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": context + } + ] + + return refinement_messages + + def generate_one_shot( + self, + focus: str, + main_theme: str, + instructions: Optional[str] = None, + allow_grounding: bool = False, + grounding_method: str = "tool_call", + date_range: Optional[Tuple[str, str]] = None, + map_type: str = "risk", + ) -> Dict[str, Any]: + """ + Generate a mind map in one LLM call, optionally allowing the LLM to request grounding. + If allow_grounding is True, use the specified grounding_method ("tool_call" or "chat"). + Optionally log intermediate steps to disk. + """ + + + messages = self.compose_base_message(main_theme, focus, map_type, instructions) + + llm_kwargs = self.llm_model_config_base.get_llm_kwargs(remove_max_tokens=True, remove_timeout=True) + if allow_grounding: + if grounding_method == "tool_call": + messages.append({"role": "user", "content": "You can use news search to find relevant information about the topic. " + "Use the Bigdata API to search for news articles related to the topic and use them to inform your response. You will need to specify a list of sentences, a list of entities, and a list of keywords."}) + tool_call_id, tool_calls, search_list, entities_list, keywords_list = self.send_tool_call(messages,self.llm_base, llm_kwargs) + + if search_list and isinstance(search_list, list): + context = self._run_and_collate_search(search_list, entities_list, keywords_list, date_range=date_range) + + final_messages = self.compose_final_message(main_theme, focus, map_type, instructions, tool_calls, tool_call_id, context) + + mindmap_text = self.llm_base.get_response(final_messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + return { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), ##where does this come from? + "grounded": True, + "search_queries": search_list, + "search_context": context + } + else: + #decide if this fallback should be simplified + mindmap_text = search_list if isinstance(search_list, str) else "" + theme_tree = self._parse_llm_to_themetree(mindmap_text) ## check if correct + df = format_mindmap_to_dataframe(mindmap_text) + return { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "grounded": False + } + else: + #decide if this fallback should be simplified + messages[0]["content"] += ( + " You may request news search to ground your mind map. " + "If you want to search, return a list of queries." + ) + response = self.llm_base.get_response(messages) + + queries = self._parse_queries(response) + + if queries: + context = self._run_and_collate_search(queries, [], []) + + followup_messages = [ + {"role": "system", "content": f"{instructions} {focus}"}, + {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)}, + {"role": "assistant", "content": "News search results:\n" + context} + ] + mindmap_text = self.llm_base.get_response(followup_messages) + + df = format_mindmap_to_dataframe(mindmap_text) + return { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "grounded": True, + "search_queries": queries, + "search_context": context + } + # Default: just generate mind map + mindmap_text = self.llm_base.get_response(messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + return { + "mindmap_text": mindmap_text, + "mindmap_tree": theme_tree, + "mindmap_json": theme_tree.to_json(), + "mindmap_df": df, + "grounded": False + } + + def generate_refined( + self, + focus: str, + main_theme: str, + initial_mindmap: str, + grounding_method: str = "tool_call", + output_dir:str = "./refined_mindmaps", + filename:str = "refined_mindmap.json", + map_type: str = "risk", + instructions: Optional[str] = None, + search_scope: Optional[Any] = None, + sortby: Optional[Any] = None, + date_range: Optional[Any] = None, + chunk_limit: Optional[int] = 20, + **llm_kwargs + ) -> Dict[str, Any]: + """ + Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. + Optionally log intermediate steps to disk. + """ + messages = self.compose_tool_call_message(main_theme, focus, map_type, instructions, initial_mindmap) + llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs(remove_max_tokens=True, remove_timeout=True) + if grounding_method == "tool_call": + tool_call_id, tool_calls, search_list, entities_list, keywords_list = self.send_tool_call( + messages,self.llm_reasoning, llm_kwargs=llm_kwargs) + + if search_list and isinstance(search_list, list): + context = self._run_and_collate_search( + search_list, entities_list, keywords_list, search_scope, sortby, date_range, chunk_limit + ) + + refinement_messages = self.compose_refinement_message(main_theme, focus, map_type, instructions, initial_mindmap, context, tool_calls, tool_call_id) + mindmap_text = self.llm_reasoning.get_response(refinement_messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "search_queries": search_list, + "search_context": context + } + save_results_to_file(result_dict, output_dir, filename) + return result_dict + else: + mindmap_text = search_list if isinstance(search_list, str) else "" + df = format_mindmap_to_dataframe(mindmap_text) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "search_queries": [], + "search_context": "" + } + save_results_to_file(result_dict, output_dir, filename) + return result_dict + else: + queries_json = self.llm_reasoning.get_response(messages) + + search_queries = self._parse_queries(queries_json) + context = self._run_and_collate_search( + search_queries, [], [], search_scope, sortby, date_range, chunk_limit + ) + + refinement_messages = self.compose_refinement_message(main_theme, focus, map_type, instructions, initial_mindmap, context, tool_calls, tool_call_id) + mindmap_text = self.llm_reasoning.get_response(refinement_messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "search_queries": search_queries, + "search_context": context + } + save_results_to_file(result_dict, output_dir, filename) + return result_dict + + def generate_or_load_refined(self, instructions: str, + focus: str, + main_theme: str, + map_type: str, + initial_mindmap: str, + llm_model: str = "o3-mini", + reasoning_effort: str = "high", + search_scope: Any = None, + sortby: Any = None, + date_range: Any = None, + chunk_limit: int = 20, + grounding_method: str = "tool_call", + output_dir:str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + i: int = 0): + if f"{filename}_{i}.json" in os.listdir(output_dir): + result = load_results_from_file(output_dir, f"{filename}_{i}.json") + print(f"Loaded existing result for {filename}_{i}.json") + else: + try: + result = self.generate_refined( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + reasoning_effort=reasoning_effort, + grounding_method=grounding_method, + date_range=date_range, + output_dir=output_dir, + filename = f"{filename}_{i}.json" + ) + #save_results_to_file(result, output_dir, ) + except Exception as e: + print(e) + result = self.generate_refined( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + reasoning_effort=reasoning_effort, + grounding_method=grounding_method, + date_range=date_range, + output_dir=output_dir, + filename = f"{filename}_{i}.json" + ) + #save_results_to_file(result, output_dir, f"{filename}_{i}.json") + return result + + def bootstrap_refined(self, instructions: str, + focus: str, + main_theme: str, + map_type: str, + initial_mindmap: str, + search_scope: Any = None, + sortby: Any = None, + date_range: Any = None, + chunk_limit: int = 20, + grounding_method: str = "tool_call", + output_dir: str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + n_elements: int = 50, + max_workers: int = 10): + """ + Generate multiple refined mindmaps in parallel using ThreadPoolExecutor. + + Generates n_elements mindmaps by calling generate_or_load_refined for each index. + Uses a thread pool to parallelize the generation process for better efficiency. + Each mindmap is saved with an index suffix to the output_dir. + + Returns a list of all generated mindmap results. + """ + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + refined_results = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Create a mapping of futures to their corresponding indices + future_to_index = {} + + # Submit all tasks and track which future corresponds to which index + for i in range(n_elements): + future = executor.submit( + self.generate_or_load_refined, + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + search_scope=search_scope, + sortby=sortby, + date_range=date_range, + chunk_limit=chunk_limit, + grounding_method=grounding_method, + output_dir=output_dir, + filename=filename, + i=i + ) + future_to_index[future] = i + + # Process futures as they complete + for future in tqdm( + as_completed(future_to_index), total=n_elements, desc="Bootstrapping Refined Mindmaps..." + ): + i = future_to_index[future] + try: + # Store the result in the list + refined_results.append(future.result()) + except Exception as e: + print(f"Error in generating mindmap {i}: {e}") + + return refined_results + + def generate_dynamic( + self, + instructions: str, + focus: str, + main_theme: str, + month_intervals: List[Tuple[str, str]], + month_names: List[str], + search_scope: Any = None, + sortby: Any = None, + chunk_limit: int = 20, + grounding_method: str = "tool_call", + map_type: str = "risk", + output_dir: str = "./dynamic_mindmaps", + **llm_kwargs + ) -> List[Dict[str, Any]]: + """ + Dynamic/iterative mind map generation over time intervals. + Returns a list of dicts, one per interval. + Each step: generate/refine mind map for the given interval, grounded in search results for that period. + """ + results = {} + # Step 1: Generate initial mind map for t0 + one_shot = self.generate_one_shot( + instructions, focus, main_theme, map_type=map_type, **llm_kwargs + ) + prev_mindmap = one_shot["mindmap_text"] + results['base_mindmap'] = one_shot + # Step 2: For each subsequent interval, refine using previous mind map and new search, including starting month + for i, (interval, month_name) in enumerate(zip(month_intervals, month_names), start=0): + date_range = self._make_absolute_date_range(interval) + refined = self.generate_refined(focus = focus, + main_theme=main_theme, + initial_mindmap=prev_mindmap, + grounding_method=grounding_method, + output_dir=output_dir, + filename=f"{month_name}.json", + map_type=map_type, + instructions=instructions, + search_scope=search_scope, + sortby=sortby, + date_range=date_range, + chunk_limit=chunk_limit, + **llm_kwargs + ) + + results[month_name] = refined + prev_mindmap = refined["mindmap_text"] + return results + + def _run_and_collate_search( + self, + search_list: List[str], + entities_list: List[str], + keywords_list: List[str], + search_scope: Any = None, + sortby: Any = None, + date_range: Any = None, + chunk_limit: int = 20 + ) -> str: + """ + Run Bigdata search for each query and collate results for LLM context. + Uses sensible defaults for scope, sortby, and date_range. + If date_range is a list of one tuple (e.g. [('2025-01-01', '2025-01-31')]), unpacks it. + If date_range is a tuple/list of two strings, converts to AbsoluteDateRange. + """ + + # Set defaults if not provided + scope = search_scope if search_scope is not None else DocumentType.NEWS + sortby = sortby if sortby is not None else SortBy.RELEVANCE + + # --- Robust date_range parsing --- + # If date_range is a list of one tuple, unpack it + if isinstance(date_range, list) and len(date_range) == 1 and isinstance(date_range[0], (tuple, list)) and len(date_range[0]) == 2: + date_range = date_range[0] + # If date_range is a tuple/list of two strings, convert to AbsoluteDateRange + if isinstance(date_range, (tuple, list)) and len(date_range) == 2 and all(isinstance(x, str) for x in date_range): + date_range = AbsoluteDateRange(start=date_range[0], end=date_range[1]) + elif date_range is None: + date_range = RollingDateRange.LAST_THIRTY_DAYS + + if entities_list: + print(f"Entities List: {entities_list}") + entity_objs = [] + for entity_name in entities_list: + try: + entity = self.bigdata_connection.knowledge_graph.autosuggest(entity_name, limit=1)[0] + entity_objs.append(entity) + except Exception as e: + print(f"Warning: Autosuggest failed for '{entity_name}': {e}") + continue + print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") + confirmed_entities = [entity for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name] + if confirmed_entities: + entities = Any(confirmed_entities) + else: + entities = None + else: + entities = None + if keywords_list: + print(f"Searching with keywords: {keywords_list}") + keywords = Any([Keyword(kw) for kw in keywords_list]) + else: + keywords = None + + queries = [Similarity(sentence)&keywords&entities if keywords or entities else Similarity(sentence) for sentence in search_list] + + all_results = run_search(queries=queries, + date_ranges = date_range, + sortby = sortby, + scope = scope, + limit = chunk_limit, + only_results = False, + rerank_threshold = None) + + return self.collate_results(all_results) + + def collate_results(self, results: List[Tuple[str, Any]]) -> str: + """ + Collate a list of (query, result) tuples into a single string for LLM context. + + Args: + results (list): List of (query, result) tuples. + + Returns: + str: Collated string for LLM context. + """ + doctexts = [] + for (text_query, date_range), result in results.items(): + docstr = f"###Query: {text_query}\n ### Results:\n" + for doc in result: + headline = getattr(doc, "headline", "No headline") + docstr += f"## {headline}\n\n##" + docstr += f"Date: {doc.timestamp.strftime('%Y-%m-%d')}\n\n" + if hasattr(doc, "chunks"): + for chunk in doc.chunks: + docstr += f"{chunk.text}\n" + doctexts.append(docstr) + return "\n".join(doctexts) + + @staticmethod + def _parse_queries(self, queries_json: str) -> List[str]: + """ + Parse LLM output (JSON or text) into a list of search queries. + """ + import json + try: + queries = json.loads(queries_json) + if isinstance(queries, list): + return queries + elif isinstance(queries, dict) and "search_list" in queries: + return queries["search_list"] + elif isinstance(queries, dict) and "queries" in queries: + return queries["queries"] + except Exception: + # Fallback: split by lines + return [q.strip() for q in queries_json.splitlines() if q.strip()] + return [] + + @staticmethod + def _make_absolute_date_range(interval: Tuple[str, str]) -> Any: + """ + Helper to create an AbsoluteDateRange object from a (start, end) tuple. + """ + return AbsoluteDateRange(start=interval[0], end=interval[1]) \ No newline at end of file diff --git a/src/bigdata_research_tools/mindmap/mindmap_utils.py b/src/bigdata_research_tools/mindmap/mindmap_utils.py new file mode 100644 index 0000000..7265641 --- /dev/null +++ b/src/bigdata_research_tools/mindmap/mindmap_utils.py @@ -0,0 +1,121 @@ +import pandas as pd +from io import StringIO +import os +import json + +prompts_dict = {'theme':{'qualifier':'Main Theme', + 'user_prompt_message':'Your given Theme is: {main_theme}', + 'enforce_structure_string':("""IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" + "- `node`: an integer representing the unique identifier for the node.\n" + "- `label`: a string for the name of the sub-theme.\n" + "- `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme.\n" + "- For the node referring to the main theme, just define briefly in maximum 15 words the theme.\n" + "- `children`: an array of child nodes.\n" + "Format the JSON object as a nested dictionary. Be careful when specifying keys and items.\n" + "Avoid overlapping labels. Break down joint concepts into unique parents so that each parent represents ONLY ONE concept. AVOID creating branch names such as 'Compliance and Regulatory Risk'. Keep risks separate and create a single branch for each risk, such as 'Compliance Risk' and 'Regulatory Risk', each with their own children.\n" + "Return ONLY the JSON object, with no extra text, explanation, or markdown.\n" + "You MUST use ONLY these field names: label, node, summary, children. Do NOT use underscores, spaces, or any other characters in field names. If you use any other field names, your answer will be rejected.\n" + "## Example Structure:\n" + "**Theme: Global Warming**\n\n" + "{\n" + " \"node\": 1,\n" + " \"label\": \"Global Warming\",\n" + " \"summary\": \"Global Warming is a serious risk\",\n" + " \"children\": [\n" + " {\"node\": 2, \"label\": \"Renewable Energy Adoption\", \"summary\": \"Renewable energy reduces greenhouse gas emissions and thereby global warming and climate change effects\", \"children\": [\n" + " {\"node\": 5, \"label\": \"Solar Energy\", \"summary\": \"Solar energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 6, \"label\": \"Wind Energy\", \"summary\": \"Wind energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 7, \"label\": \"Hydropower\", \"summary\": \"Hydropower reduces greenhouse gas emissions\"}\n" + " ]},\n" + " {\"node\": 3, \"label\": \"Carbon Emission Reduction\", \"summary\": \"Carbon emission reduction decreases greenhouse gases\", \"children\": [\n" + " {\"node\": 8, \"label\": \"Carbon Capture Technology\", \"summary\": \"Carbon capture technology reduces atmospheric CO2\"},\n" + " {\"node\": 9, \"label\": \"Emission Trading Systems\", \"summary\": \"Emission trading systems incentivize reductions in greenhouse gases\"}\n" + " ]}\n" + " ]\n" + "}\n" + """)}, + 'risk':{'qualifier':'Risk Scenario', + 'user_prompt_message':'Your given Risk Scenario is: {main_theme}', + 'enforce_structure_string':( + """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" + " - `node`: an integer representing the unique identifier for the node.\n" + " - `label`: a string for the name of the sub-theme.\n" + " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the main theme or risk.\n" + " - `children`: an array of child nodes.\n" + "Format the JSON object as a nested dictionary. Be careful when specifying keys and items.\n" + "Avoid overlapping labels. Break down joint concepts into unique parents so that each parent represents ONLY ONE concept. AVOID creating branch names such as 'Compliance and Regulatory Risk'. Keep risks separate and create a single branch for each risk, such as 'Compliance Risk' and 'Regulatory Risk', each with their own children.\n" + "Return ONLY the JSON object, with no extra text, explanation, or markdown.\n" + "You MUST use ONLY these field names: label, node, summary, children. Do NOT use underscores, spaces, or any other characters in field names. If you use any other field names, your answer will be rejected.\n" + "## Example Structure:\n" + "**Theme: Global Warming**\n\n" + "{\n" + " \"node\": 1,\n" + " \"label\": \"Global Warming\",\n" + " \"summary\": \"Global Warming is a serious risk\",\n" + " \"children\": [\n" + " {\"node\": 2, \"label\": \"Renewable Energy Adoption\", \"summary\": \"Renewable energy reduces greenhouse gas emissions and thereby global warming and climate change effects\", \"children\": [\n" + " {\"node\": 5, \"label\": \"Solar Energy\", \"summary\": \"Solar energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 6, \"label\": \"Wind Energy\", \"summary\": \"Wind energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 7, \"label\": \"Hydropower\", \"summary\": \"Hydropower reduces greenhouse gas emissions\"}\n" + " ]},\n" + " {\"node\": 3, \"label\": \"Carbon Emission Reduction\", \"summary\": \"Carbon emission reduction decreases greenhouse gases\", \"children\": [\n" + " {\"node\": 8, \"label\": \"Carbon Capture Technology\", \"summary\": \"Carbon capture technology reduces atmospheric CO2\"},\n" + " {\"node\": 9, \"label\": \"Emission Trading Systems\", \"summary\": \"Emission trading systems incentivize reductions in greenhouse gases\"}\n" + " ]}\n" + " ]\n" + "}\n" + """) + } +} + +def format_mindmap_to_dataframe(mindmap_text): + """ + Parse a mind map in pipe-delimited table format into a cleaned pandas DataFrame. + Strips whitespace and removes unnamed columns. + + Args: + mindmap_text (str): The mind map content as a string in pipe-delimited format. + + Returns: + pd.DataFrame: A pandas DataFrame containing the cleaned data from the mind map. + + Raises: + ValueError: If the resulting DataFrame does not contain the required columns. + """ + try: + df = pd.read_csv(StringIO(mindmap_text.strip()), sep="|", engine="python", skiprows=[1]) + df = df.loc[:, ~df.columns.str.contains('^Unnamed')] + except Exception as e: + try: + df = pd.read_csv( + StringIO(mindmap_text.strip()), + sep="|", + engine="python", + skiprows=[1], + on_bad_lines='skip' + ) + df = df.loc[:, ~df.columns.str.contains('^Unnamed')] + except Exception as e2: + raise ValueError(f"Failed to parse mindmap text to DataFrame: {e2}") + required_columns = {"Main Branches", "Sub-Branches", "Description"} + if not required_columns.issubset(set(df.columns)): + raise ValueError(f"Missing required columns in mindmap table: {df.columns}") + return df + +def save_results_to_file(results, output_dir, filename): + """ + Save the results to a JSON file. + """ + os.makedirs(output_dir, exist_ok=True) + output_file = os.path.join(output_dir, filename) + + with open(output_file, "w") as f: + json.dump(results, f, default=str, indent=2) + +def load_results_from_file(output_dir, filename): + """ + Load the results from a JSON file. + """ + input_file = os.path.join(output_dir, filename) + with open(input_file, "r") as f: + return json.load(f) \ No newline at end of file diff --git a/src/bigdata_research_tools/search/query_builder.py b/src/bigdata_research_tools/search/query_builder.py index 6b4bf13..a653d82 100644 --- a/src/bigdata_research_tools/search/query_builder.py +++ b/src/bigdata_research_tools/search/query_builder.py @@ -73,7 +73,7 @@ def build_similarity_queries(sentences: list[str]) -> list[QueryComponent]: def build_batched_query( - sentences: list[str], + sentences: list[str] | None, keywords: list[str] | None, entities: EntitiesToSearch | None, control_entities: EntitiesToSearch | None, diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 541fa50..6c7ec06 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -34,6 +34,7 @@ tuple[datetime, datetime], RollingDateRange, list[tuple[datetime, datetime] | RollingDateRange], + AbsoluteDateRange, ] SEARCH_QUERY_RESULTS_TYPE = dict[ tuple[QueryComponent, Union[AbsoluteDateRange, RollingDateRange]], list[Document] @@ -185,7 +186,7 @@ def _search( def concurrent_search( self, queries: list[QueryComponent], - date_ranges: list[tuple[datetime, datetime] | RollingDateRange], + date_ranges: list[tuple[datetime, datetime] | RollingDateRange | AbsoluteDateRange], sortby: SortBy = SortBy.RELEVANCE, scope: DocumentType = DocumentType.ALL, limit: int = 10, @@ -244,7 +245,13 @@ def concurrent_search( as_completed(futures), total=len(futures), desc="Querying Bigdata..." ): query, date_range = futures[future] + try: + if isinstance(date_range, AbsoluteDateRange): + date_range = f"{date_range.start_dt.isoformat()}_{date_range.end_dt.isoformat()}" + elif isinstance(date_range, tuple): + date_range = f"{date_range[0].isoformat()}_{date_range[1].isoformat()}" + results[(query, date_range)] = future.result() except Exception as e: raise e @@ -265,7 +272,7 @@ def get_quota_consumed(self) -> float: def normalize_date_range( date_ranges: INPUT_DATE_RANGE, -) -> list[tuple[datetime, datetime] | RollingDateRange]: +) -> list[tuple[datetime, datetime] | RollingDateRange | AbsoluteDateRange]: if not isinstance(date_ranges, list): date_ranges = [date_ranges] @@ -335,7 +342,8 @@ def run_search( the list of the corresponding search results. """ date_ranges = normalize_date_range(date_ranges) - date_ranges.sort(key=lambda x: x[0]) + if isinstance(date_ranges[0], tuple) or isinstance(date_ranges[0], list): + date_ranges.sort(key=lambda x: x[0]) workflow_start = datetime.now() workflow_status = WorkflowStatus.UNKNOWN diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index bb9873f..9eef2ea 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -17,8 +17,8 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.tree import ( - SemanticTree, +from bigdata_research_tools.mindmap.mindmap import ( + MindMap, generate_risk_tree, ) from bigdata_research_tools.workflows.base import Workflow @@ -90,7 +90,7 @@ def __init__( def create_taxonomy(self): """Create a risk taxonomy based on the main theme and focus. Returns: - SemanticTree: The generated risk tree. + MindMap: The generated risk tree. List[str]: A list of risk summaries for the terminal nodes. List[str]: A list of terminal labels for the risk categories. """ @@ -174,7 +174,7 @@ def label_search_results( self, df_sentences, terminal_labels, - risk_tree: SemanticTree, + risk_tree: MindMap, additional_prompt_fields: list[str] | None = None, ): """ @@ -183,7 +183,7 @@ def label_search_results( Args: df_sentences (DataFrame): The DataFrame containing the search results. terminal_labels (List[str]): The terminal labels for the risk categories. - risk_tree (SemanticTree): The SemanticTree object containing the risk taxonomy. + risk_tree (MindMap): The MindMap object containing the risk taxonomy. prompt_fields (Dict): Additional fields to be used in the labeling prompt. Returns: @@ -273,7 +273,7 @@ def save_results( df_company: DataFrame, df_industry: DataFrame, motivation_df: DataFrame, - risk_tree: SemanticTree, + risk_tree: MindMap, export_path: str, ): """ @@ -330,7 +330,7 @@ def screen_companies( - df_company: The DataFrame with the output by company. - df_industry: The DataFrame with the output by industry. - df_motivation: The DataFrame with the generated motivations. - - risk_tree: The SemanticTree created for the screening. + - risk_tree: The MindMap created for the screening. """ if export_path and not check_excel_dependencies(): diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index 9e83e7d..cbf62f8 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -17,7 +17,7 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.tree import generate_theme_tree +from bigdata_research_tools.mindmap.mindmap import generate_theme_tree from bigdata_research_tools.workflows.base import Workflow from bigdata_research_tools.workflows.utils import get_scored_df From 5a0a50d52f1ddf69607e180a612d334a3c0a7c36 Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Mon, 17 Nov 2025 17:37:25 +0000 Subject: [PATCH 02/14] extracting sentence from query --- examples/grounded_mindmaps.py | 6 +++--- src/bigdata_research_tools/mindmap/mindmap_generator.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index f032b8d..5e4b8bc 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -93,9 +93,9 @@ def main(MAIN_THEME = "Political Change in Japan.", logger.info("=" * 60) try: - # base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") - # test_refined_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") - # test_refined_mindmap2(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") + base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") + test_refined_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") + test_refined_mindmap2(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") test_dynamic_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini", llm_reasoning_config="openai::o3-mini") logger.info("=" * 60) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index a53480c..dc4de88 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -681,7 +681,11 @@ def collate_results(self, results: List[Tuple[str, Any]]) -> str: """ doctexts = [] for (text_query, date_range), result in results.items(): - docstr = f"###Query: {text_query}\n ### Results:\n" + for item in text_query.items: + dictitem = item.to_dict() + if dictitem['type']=='similarity': + sentence = dictitem['value'] + docstr = f"###Query: {sentence}\n ### Results:\n" for doc in result: headline = getattr(doc, "headline", "No headline") docstr += f"## {headline}\n\n##" From ce09f9a252db7217a945d6f39545273434d89b9b Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Tue, 18 Nov 2025 10:01:28 +0000 Subject: [PATCH 03/14] added presentation style visuals --- examples/grounded_mindmaps.py | 8 +- pyproject.toml | 1 + .../mindmap/__init__.py | 4 + src/bigdata_research_tools/mindmap/mindmap.py | 18 +- .../visuals/mindmap_visuals.py | 959 ++++++++++++++++++ uv.lock | 418 +++++++- 6 files changed, 1402 insertions(+), 6 deletions(-) create mode 100644 src/bigdata_research_tools/mindmap/__init__.py create mode 100644 src/bigdata_research_tools/visuals/mindmap_visuals.py diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index 5e4b8bc..52097dd 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -3,10 +3,9 @@ from bigdata_client import Bigdata from bigdata_client.models.search import DocumentType from dotenv import load_dotenv -from traitlets import Any - from bigdata_research_tools.mindmap.mindmap import MindMap from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator +from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap # Load environment variables for authentication print(f"Environment variables loaded: {load_dotenv()}") @@ -31,7 +30,7 @@ def test_one_shot_mindmap(main_theme, focus, map_type, instructions, llm_base_co allow_grounding=False, ) logger.info("Results: %s", mindmap['mindmap_text']) - return mindmap["mindmap_json"] + return mindmap["mindmap_df"], mindmap["mindmap_json"] def test_refined_mindmap(main_theme, focus, map_type, instructions, base_mindmap: str, llm_base_config: str = "openai::o3-mini") -> MindMap: @@ -93,7 +92,8 @@ def main(MAIN_THEME = "Political Change in Japan.", logger.info("=" * 60) try: - base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") + df_mindmap, base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") + plot_mindmap(df_mindmap, MAIN_THEME) test_refined_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") test_refined_mindmap2(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") test_dynamic_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini", llm_reasoning_config="openai::o3-mini") diff --git a/pyproject.toml b/pyproject.toml index 209308f..3db1bf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "json-repair>=0.44.1", "tabulate>=0.9.0,<1.0.0", "plotly>=6.0.0,<7.0.0", + "matplotlib>=3.10.6,<4.0.0" ] [project.urls] diff --git a/src/bigdata_research_tools/mindmap/__init__.py b/src/bigdata_research_tools/mindmap/__init__.py new file mode 100644 index 0000000..10b1154 --- /dev/null +++ b/src/bigdata_research_tools/mindmap/__init__.py @@ -0,0 +1,4 @@ +from bigdata_research_tools.mindmap.mindmap import MindMap, generate_theme_tree, generate_risk_tree +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator + +__all__ = ["MindMap", "MindMapGenerator", "generate_theme_tree", "generate_risk_tree"] diff --git a/src/bigdata_research_tools/mindmap/mindmap.py b/src/bigdata_research_tools/mindmap/mindmap.py index c047dc4..2846a9d 100644 --- a/src/bigdata_research_tools/mindmap/mindmap.py +++ b/src/bigdata_research_tools/mindmap/mindmap.py @@ -192,11 +192,27 @@ def visualize(self, engine: str = "graphviz") -> None: self._visualize_graphviz() elif engine == "plotly": self._visualize_plotly() + elif engine == "matplotlib": + self._visualize_matplotlib() else: raise ValueError( f"Unsupported engine '{engine}'. " - f"Supported engines are 'graphviz' and 'plotly'." + f"Supported engines are 'graphviz', 'plotly', and 'matplotlib'." ) + + def _visualize_matplotlib(self): + """ + Auxiliary function to visualize the tree using Matplotlib. + + Returns: + A Matplotlib Plot rendering the mindmap. + """ + import matplotlib + matplotlib.use('Agg') # Use non-interactive backend + from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap + + plot_mindmap(self.to_dataframe(), main_theme=self.label) + def _visualize_graphviz(self) -> graphviz.Digraph: """ diff --git a/src/bigdata_research_tools/visuals/mindmap_visuals.py b/src/bigdata_research_tools/visuals/mindmap_visuals.py new file mode 100644 index 0000000..3e1a2f7 --- /dev/null +++ b/src/bigdata_research_tools/visuals/mindmap_visuals.py @@ -0,0 +1,959 @@ +""" +Mindmap Plotter - A graphviz-inspired tool for creating mindmap visualizations. + +Clean, structured layout with proper text fitting and spacing. + +Algorithm Overview: +------------------- +The tool uses a multi-stage layout algorithm to ensure all text fits within rectangles +and the entire mindmap fits within the chart area: + +1. **Layout Calculation Phase**: + - Calculates base font size based on total available area and number of elements + - Determines uniform dimensions for each node level (theme, main branches, sub-branches) + - Applies horizontal and vertical scaling if needed to fit within chart bounds + - All scaling happens BEFORE text fitting to ensure accurate measurements + +2. **Text Fitting Algorithm**: + - Uses binary search (30-40 iterations) to find optimal font size for each text element + - Text wrapping respects word boundaries only (no mid-word breaks) + - Separate fitting strategies for main branches (strict, 88% safety margin) and + sub-branches (prioritizes larger fonts, 90% safety margin, minimal wrapping) + - Measures actual rendered text dimensions on the target figure for accuracy + +3. **Iterative Refinement Loop**: + - Renders the plot and measures actual text dimensions + - Detects overflow (text exceeding rectangle bounds or rectangles exceeding chart area) + - Adjusts font sizes and dimensions iteratively (up to 5 iterations) + - Continues until no overflow is detected + +4. **Boundary Enforcement**: + - Accounts for rectangle border linewidth (extends outward from edges) + - Uses absolute bottom boundary to prevent bottom border cutoff + - Centers sub-branch blocks with their main branch while respecting boundaries + - Clips all elements to axes limits to prevent rendering outside chart area + +The algorithm guarantees no text overflow, no ellipsis, and proper spacing while +maintaining visual hierarchy and readability. +""" +import matplotlib +matplotlib.use('Agg') # Use non-interactive backend +import matplotlib.pyplot as plt +import matplotlib.patches as patches +from matplotlib import rcParams +import pandas as pd +import numpy as np +import math +import os +from typing import Tuple, Dict, List, Optional +from bigdata_research_tools.mindmap.mindmap import MindMap + +##Note: In case the mindmap overflows from the chart area and edges, you can try to adjust the padding system by shrinking the 'available chart area'. For example, if padding does not work, you can try to have the algorithm think that the available area is 95% of the original available area. In this way, the algorithm will have less space to work with and will try to fit the text within the available area. + +class MindmapPlotter: + """Main class for plotting mindmaps with graphviz-inspired layout.""" + + def __init__( + self, + mindmap: pd.DataFrame | MindMap, + main_theme: str, + title: str = "Mind Map", + color_scheme: str = "gold", + output_dir: str = "./outputs", + aspect_ratio: float = 8/9 + ): + """Initialize the mindmap plotter.""" + if isinstance(mindmap, pd.DataFrame): + self.df = mindmap.copy() + elif isinstance(mindmap, MindMap): + self.df = mindmap.to_dataframe() + + # Handle flexible column names: either (Parent, Label) or (Main Branches, Sub-Branches) + if 'Main Branches' in self.df.columns and 'Sub-Branches' in self.df.columns: + # Already in correct format + pass + elif 'Parent' in self.df.columns and 'Label' in self.df.columns: + # Rename to standard format + self.df = self.df.rename(columns={'Parent': 'Main Branches', 'Label': 'Sub-Branches'}) + else: + # Check what columns we have + has_main = 'Main Branches' in self.df.columns + has_sub = 'Sub-Branches' in self.df.columns + has_parent = 'Parent' in self.df.columns + has_label = 'Label' in self.df.columns + + raise ValueError( + f"DataFrame must have either (Parent, Label) or (Main Branches, Sub-Branches) columns. " + f"Found columns: {list(self.df.columns)}. " + f"Has Main Branches: {has_main}, Has Sub-Branches: {has_sub}, " + f"Has Parent: {has_parent}, Has Label: {has_label}" + ) + + # Assert required columns exist + assert 'Main Branches' in self.df.columns, "Missing 'Main Branches' column after processing" + assert 'Sub-Branches' in self.df.columns, "Missing 'Sub-Branches' column after processing" + + self.df['Main Branches'] = self.df['Main Branches'].astype(str).str.strip() + self.df['Sub-Branches'] = self.df['Sub-Branches'].astype(str).str.strip() + + self.main_theme = main_theme + self.title = title + self.color_scheme = color_scheme + self.output_dir = output_dir + self.aspect_ratio = aspect_ratio + + os.makedirs(output_dir, exist_ok=True) + + plt.rcParams['svg.fonttype'] = 'none' + rcParams['font.family'] = 'DejaVu Sans' + + self.colors = self._get_color_scheme(color_scheme) + + self.main_branches = self.df['Main Branches'].unique() + self.n_main = len(self.main_branches) + + self.sub_branches = { + main: self.df[self.df['Main Branches'] == main]['Sub-Branches'].tolist() + for main in self.main_branches + } + self.n_sub_total = sum(len(subs) for subs in self.sub_branches.values()) + + self.fig_width = 10.0 + self.fig_height = self.fig_width / aspect_ratio + self.title_height = 0 # No title - removed to maximize space + + # Add padding to prevent rectangles from going over chart edges + self.edge_padding = 0.25 # Padding on sides + self.top_padding = 0.1 # Minimal top padding + self.bottom_padding = 0.6 # Extra bottom padding to ensure borders don't get cut (increased further) + # Account for linewidth - borders extend outward by half linewidth on each side + self.max_linewidth = 2.5 # Maximum linewidth used (for main branches/theme) + self.sub_linewidth = 2.0 # Sub-branch linewidth + # Linewidth extends by half on each side, convert points to inches (72 points per inch) + # Be very conservative: use 3x linewidth as padding to ensure borders don't get cut + self.linewidth_padding = (self.max_linewidth / 72.0) * 3.0 # Very conservative padding + self.sub_linewidth_padding = (self.sub_linewidth / 72.0) * 3.0 # Sub-branch linewidth padding + # Use minimal top padding, extra bottom padding + # Reduce available_height to account for linewidth extension at bottom + self.available_height = self.fig_height - self.title_height - self.top_padding - (self.bottom_padding + self.sub_linewidth_padding) + self.available_width = self.fig_width - 2 * (self.edge_padding + self.linewidth_padding) + # Calculate absolute bottom boundary - no rectangle should exceed this + # Use sub_linewidth_padding since sub-branches are at the bottom + self.absolute_bottom = self.fig_height - self.bottom_padding - self.sub_linewidth_padding + + # Increased padding for text inside boxes + self.pad_x = 0.7 # Increased from 0.5 + self.pad_y = 0.7 # Increased from 0.5 + + def _get_color_scheme(self, scheme: str) -> Dict[str, str]: + """Get color scheme dictionary.""" + schemes = { + "gold": { + "edge": "#eab720", + "link": "#eab720", + "background": "none", # Transparent + "text": "black" + }, + "light_blue": { + "edge": "#206EB5", + "link": "#206EB5", + "background": "none", # Transparent + "text": "black" + }, + "dark_blue": { + "edge": "#2C318C", + "link": "#2C318C", + "background": "none", # Transparent + "text": "black" + } + } + return schemes.get(scheme, schemes["gold"]) + + def _measure_text(self, ax, text: str, fontsize: float) -> Tuple[float, float]: + """Measure text dimensions accurately.""" + if not text: + return self.pad_x, self.pad_y + + t = ax.text(0, 0, text, fontsize=fontsize, ha='left', va='bottom') + if not hasattr(ax.figure.canvas, 'renderer'): + ax.figure.canvas.draw() + renderer = ax.figure.canvas.get_renderer() + bbox = t.get_window_extent(renderer=renderer) + t.remove() + + width = bbox.width / ax.figure.dpi + self.pad_x + height = bbox.height / ax.figure.dpi + self.pad_y + + return width, height + + def _wrap_text(self, text: str, max_chars: int) -> str: + """Wrap text at word boundaries.""" + if not text: + return text + + lines = [] + for para in text.split('\n'): + if not para: + lines.append('') + continue + + words = para.split() + if not words: + lines.append('') + continue + + line = words[0] + for word in words[1:]: + if len(line + ' ' + word) <= max_chars: + line += ' ' + word + else: + lines.append(line) + line = word + lines.append(line) + + return '\n'.join(lines) + + def _fit_text_main( + self, + ax, + text: str, + max_width: float, + max_height: float, + initial_font: float, + min_font: float = 9 + ) -> Tuple[float, str, float, float]: + """ + Fit text for main branches - STRICT no overflow. + Returns: fontsize, wrapped_text, actual_width, actual_height + """ + if not text: + return min_font, '', self.pad_x, self.pad_y + + available_w = max(0.1, max_width - self.pad_x) + available_h = max(0.1, max_height - self.pad_y) + + # Binary search for optimal font size + low_font = min_font + high_font = initial_font * 1.3 + best = None + + for _ in range(40): + test_font = (low_font + high_font) / 2.0 + + # Less aggressive wrapping - prioritize font size + chars_per_inch = test_font / 12.0 * 7 # More chars per inch + wrap_chars = max(20, int(available_w * chars_per_inch)) # Minimum 20 chars + + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, test_font) + + if w <= max_width * 0.88 and h <= max_height * 0.88: + best = (test_font, wrapped, w, h) + low_font = test_font + 0.2 + else: + high_font = test_font - 0.2 + + if high_font < low_font: + break + + if best: + return best + + # Fallback: ensure it fits + fontsize = min_font + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Keep reducing until it fits - more aggressive + max_attempts = 60 + attempt = 0 + while (w > max_width * 0.88 or h > max_height * 0.88) and fontsize > min_font * 0.7 and attempt < max_attempts: + attempt += 1 + scale = min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) * 0.97 + fontsize = max(min_font * 0.7, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final verification - ensure text actually fits + w, h = self._measure_text(ax, wrapped, fontsize) + if w > max_width * 0.88 or h > max_height * 0.88: + # Force one more reduction + scale = min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) * 0.98 + fontsize = max(min_font * 0.7, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int((max_width - self.pad_x) * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final safety check - clip dimensions + w = min(w, max_width * 0.88) + h = min(h, max_height * 0.88) + + return fontsize, wrapped, w, h + + def _fit_text_sub( + self, + ax, + text: str, + max_width: float, + max_height: float, + initial_font: float, + min_font: float = 8 + ) -> Tuple[float, str, float, float]: + """ + Fit text for sub-branches - prioritize large font, minimal wrapping. + Returns: fontsize, wrapped_text, actual_width, actual_height + """ + if not text: + return min_font, '', self.pad_x, self.pad_y + + available_w = max(0.1, max_width - self.pad_x) + available_h = max(0.1, max_height - self.pad_y) + + # Binary search prioritizing larger fonts + low_font = min_font + high_font = initial_font * 1.5 + best = None + + for _ in range(40): + test_font = (low_font + high_font) / 2.0 + + # Much less aggressive wrapping - wide lines preferred + chars_per_inch = test_font / 12.0 * 8 # Even more chars per inch + wrap_chars = max(25, int(available_w * chars_per_inch)) # Minimum 25 chars, prefer wide lines + + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, test_font) + + if w <= max_width * 0.90 and h <= max_height * 0.90: + best = (test_font, wrapped, w, h) + low_font = test_font + 0.3 # Try even larger + else: + high_font = test_font - 0.3 + + if high_font < low_font: + break + + if best: + return best + + # Fallback: use minimum font with wide wrapping + fontsize = min_font + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Only reduce if absolutely necessary + max_attempts = 30 + attempt = 0 + while (w > max_width * 0.90 or h > max_height * 0.90) and fontsize > min_font * 0.85 and attempt < max_attempts: + attempt += 1 + scale = min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) * 0.98 + fontsize = max(min_font * 0.85, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final verification - ensure text actually fits + w, h = self._measure_text(ax, wrapped, fontsize) + if w > max_width * 0.90 or h > max_height * 0.90: + # Force one more reduction + scale = min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) * 0.99 + fontsize = max(min_font * 0.85, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int((max_width - self.pad_x) * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final safety check - clip dimensions + w = min(w, max_width * 0.90) + h = min(h, max_height * 0.90) + + return fontsize, wrapped, w, h + + def _calculate_layout(self) -> Dict: + """Calculate complete layout with uniform dimensions and scaling.""" + fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) + ax_temp = fig_temp.add_subplot(111) + ax_temp.axis('off') + + # Base font calculation + total_elements = self.n_main + self.n_sub_total + area_per_element = (self.available_height * self.available_width) / max(1, total_elements) + + ref_area = 1.0 + if area_per_element > ref_area: + area_factor = math.pow(area_per_element / ref_area, 0.6) + else: + area_factor = math.pow(area_per_element / ref_area, 0.7) + + area_factor = max(0.4, min(3.5, area_factor)) + base_font = max(8, min(26, 12 * area_factor)) + + # Initial dimensions (will be scaled if needed) + main_spacing = self.available_height * 0.06 + available_h = self.available_height - (self.n_main - 1) * main_spacing + uniform_main_h = max(0.7, min(1.6, available_h / max(1, self.n_main))) + + max_main_len = max([len(m) for m in self.main_branches], default=20) + uniform_main_w = max(1.3, min(2.5, max_main_len * 0.10)) + + # Theme width matches main branch width + theme_w = uniform_main_w + theme_h = uniform_main_h + + # Sub-branch dimensions + max_subs = max([len(subs) for subs in self.sub_branches.values()], default=1) + spacing_extra = main_spacing * 0.4 + available_sub_h = uniform_main_h + 2 * spacing_extra + + if max_subs > 0: + sub_spacing = 0.12 + available_for_subs = available_sub_h - (max_subs - 1) * sub_spacing + uniform_sub_h = max(0.55, min(1.5, available_for_subs / max_subs)) + else: + uniform_sub_h = 0.8 + sub_spacing = 0.12 + + all_subs = [s for subs in self.sub_branches.values() for s in subs] + max_sub_len = max([len(s) for s in all_subs], default=30) if all_subs else 30 + max_w = self.available_width * 0.28 + uniform_sub_w = max(2.2, min(max_w, max_sub_len * 0.12)) + + # Calculate column positions (accounting for edge padding and linewidth) + margin = self.edge_padding + self.linewidth_padding + col_spacing = self.available_width * 0.07 + + x_theme = margin + x_main = x_theme + theme_w + col_spacing + x_sub = x_main + uniform_main_w + col_spacing + total_width_needed = x_sub + uniform_sub_w + margin + + # Horizontal scaling if needed + h_scale = 1.0 + if total_width_needed > self.available_width * 0.96: + h_scale = (self.available_width * 0.96) / total_width_needed + uniform_main_w *= h_scale + theme_w *= h_scale + uniform_sub_w *= h_scale + margin *= h_scale + col_spacing *= h_scale + # Recalculate positions + x_theme = margin + x_main = x_theme + theme_w + col_spacing + x_sub = x_main + uniform_main_w + col_spacing + + # Vertical scaling check + total_h = uniform_main_h * self.n_main + main_spacing * (self.n_main - 1) + v_scale = 1.0 + if total_h > self.available_height * 0.94: + v_scale = (self.available_height * 0.94) / total_h + uniform_main_h *= v_scale + theme_h *= v_scale + uniform_sub_h *= v_scale + main_spacing *= v_scale + sub_spacing *= v_scale + + # Now fit text with final scaled dimensions + # Fit all main branches to find uniform font + main_fonts = [] + for main in self.main_branches: + font, _, _, _ = self._fit_text_main(ax_temp, main, uniform_main_w, uniform_main_h, base_font * 0.9, 10) + main_fonts.append(font) + + uniform_main_font = min(main_fonts) if main_fonts else base_font * 0.9 + + # Fit all main branches with uniform dimensions - STRICT no overflow + main_data = {} + for main in self.main_branches: + font, wrapped, w, h = self._fit_text_main(ax_temp, main, uniform_main_w, uniform_main_h, uniform_main_font, 10) + main_data[main] = { + 'fontsize': font, + 'text': wrapped, + 'width': uniform_main_w, + 'height': uniform_main_h + } + + # Theme layout - use same width as main + theme_font, theme_wrapped, theme_w_actual, theme_h_actual = self._fit_text_main( + ax_temp, self.main_theme, theme_w, theme_h, base_font * 1.0, 11 + ) + # Use target dimensions, not measured + theme_w = uniform_main_w + theme_h = uniform_main_h + + # Fit all sub-branches - prioritize large font, minimal wrapping + sub_fonts = [] + for sub in all_subs: + font, _, _, _ = self._fit_text_sub(ax_temp, sub, uniform_sub_w * 0.94, uniform_sub_h * 0.94, base_font * 1.0, 9) + sub_fonts.append(font) + + uniform_sub_font = min(sub_fonts) if sub_fonts else base_font * 1.0 + + # Fit all sub-branches with uniform dimensions + sub_data = {} + for main in self.main_branches: + subs = self.sub_branches[main] + main_sub_data = [] + + for sub in subs: + font, wrapped, w, h = self._fit_text_sub( + ax_temp, sub, uniform_sub_w * 0.94, uniform_sub_h * 0.94, uniform_sub_font, 9 + ) + + main_sub_data.append({ + 'fontsize': font, + 'text': wrapped, + 'width': uniform_sub_w, + 'height': uniform_sub_h + }) + + sub_data[main] = main_sub_data + + plt.close(fig_temp) + + return { + 'theme': {'fontsize': theme_font, 'text': theme_wrapped, 'width': theme_w, 'height': theme_h}, + 'main_data': main_data, + 'sub_data': sub_data, + 'uniform_main_w': uniform_main_w, + 'uniform_main_h': uniform_main_h, + 'uniform_sub_w': uniform_sub_w, + 'uniform_sub_h': uniform_sub_h, + 'main_spacing': main_spacing, + 'sub_spacing': sub_spacing, + 'margin': margin, + 'col_spacing': col_spacing, + 'x_theme': x_theme, + 'x_main': x_main, + 'x_sub': x_sub + } + + def _check_overflow(self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) -> Dict: + """Check for text and rectangle overflow, return adjustments needed.""" + issues = { + 'theme_overflow': False, + 'main_overflows': {}, + 'sub_overflows': {}, + 'bottom_cutoff': False + } + + # Check theme text overflow + theme_text = ax.text( + x_theme + layout['theme']['width'] / 2, + theme_y + layout['theme']['height'] / 2, + layout['theme']['text'], + ha='center', va='center', fontsize=layout['theme']['fontsize'], + fontweight='bold' + ) + renderer = ax.figure.canvas.get_renderer() + bbox = theme_text.get_window_extent(renderer=renderer) + theme_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if text_w > layout['theme']['width'] * 0.88 or text_h > layout['theme']['height'] * 0.88: + issues['theme_overflow'] = True + + # Check main branch text overflow + for main in self.main_branches: + main_y = main_positions[main] + main_info = layout['main_data'][main] + main_text = ax.text( + x_main + layout['uniform_main_w'] / 2, + main_y + layout['uniform_main_h'] / 2, + main_info['text'], + ha='center', va='center', fontsize=main_info['fontsize'], + fontweight='bold' + ) + bbox = main_text.get_window_extent(renderer=renderer) + main_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if text_w > layout['uniform_main_w'] * 0.88 or text_h > layout['uniform_main_h'] * 0.88: + issues['main_overflows'][main] = True + + # Check sub-branch overflow and bottom cutoff + max_bottom_y = 0 + for main in self.main_branches: + main_y = main_positions[main] + subs = layout['sub_data'].get(main, []) + if subs: + spacing_extra = layout['main_spacing'] * 0.4 + min_sub_y = main_y - spacing_extra + max_sub_y = main_y + layout['uniform_main_h'] + spacing_extra + + sub_spacing = layout['sub_spacing'] + total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing + main_center = main_y + layout['uniform_main_h'] / 2 + sub_y_start = main_center - total_sub_h / 2 + + if sub_y_start < min_sub_y: + sub_y_start = min_sub_y + if sub_y_start + total_sub_h > max_sub_y: + sub_y_start = max_sub_y - total_sub_h + # Calculate maximum allowed bottom position (use absolute bottom) + min_top_y = self.top_padding + self.linewidth_padding + + if sub_y_start < min_top_y: + sub_y_start = min_top_y + # Ensure total height doesn't exceed absolute bottom + if sub_y_start + total_sub_h > self.absolute_bottom: + sub_y_start = max(min_top_y, self.absolute_bottom - total_sub_h) + + sub_y = sub_y_start + for i, sub_info in enumerate(subs): + if sub_y + layout['uniform_sub_h'] > max_bottom_y: + issues['bottom_cutoff'] = True + break + + # Check text overflow + sub_text = ax.text( + x_sub + layout['uniform_sub_w'] / 2, + sub_y + layout['uniform_sub_h'] / 2, + sub_info['text'], + ha='center', va='center', fontsize=sub_info['fontsize'], + fontweight='bold' + ) + bbox = sub_text.get_window_extent(renderer=renderer) + sub_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if text_w > layout['uniform_sub_w'] * 0.90 or text_h > layout['uniform_sub_h'] * 0.90: + issues['sub_overflows'][(main, i)] = True + + bottom_y = sub_y + layout['uniform_sub_h'] + if bottom_y > max_bottom_y: + max_bottom_y = bottom_y + + sub_y += layout['uniform_sub_h'] + sub_spacing + if sub_y + layout['uniform_sub_h'] > max_sub_y: + break + + # Check if bottom sub-branch is too close to edge (accounting for padding and linewidth) + if max_bottom_y > self.absolute_bottom - 0.1: + issues['bottom_cutoff'] = True + + return issues + + def plot(self) -> Tuple[plt.Figure, plt.Axes]: + """Create the mindmap plot with iterative refinement.""" + layout = self._calculate_layout() + + # Iterative refinement loop + max_iterations = 5 + for iteration in range(max_iterations): + fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) + ax.axis('off') + + # Use pre-calculated positions from layout + x_theme = layout['x_theme'] + x_main = layout['x_main'] + x_sub = layout['x_sub'] + + # Main branch positions + total_h = layout['uniform_main_h'] * self.n_main + layout['main_spacing'] * (self.n_main - 1) + y_start = (self.available_height - total_h) / 2 + + main_positions = {} + y = y_start + for main in self.main_branches: + main_positions[main] = y + y += layout['uniform_main_h'] + layout['main_spacing'] + + # Theme position (centered with main branches) + theme_y = y_start + (total_h - layout['theme']['height']) / 2 + + # Draw elements to check overflow + self._draw_elements(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) + + # Ensure figure is drawn for accurate measurement + ax.figure.canvas.draw() + + # Check for overflow + issues = self._check_overflow(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) + + # If no issues, we're done + if not issues['theme_overflow'] and not issues['main_overflows'] and not issues['sub_overflows'] and not issues['bottom_cutoff']: + plt.close(fig) + break + + # Adjust layout based on issues + if issues['theme_overflow'] or issues['main_overflows'] or issues['bottom_cutoff']: + # Reduce font sizes for theme and main branches + layout = self._adjust_layout_for_overflow(layout, issues, iteration) + plt.close(fig) + continue + + plt.close(fig) + break + + # Final render + fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) + ax.axis('off') + + # Use pre-calculated positions from layout + x_theme = layout['x_theme'] + x_main = layout['x_main'] + x_sub = layout['x_sub'] + + # Main branch positions (accounting for top padding and linewidth) + total_h = layout['uniform_main_h'] * self.n_main + layout['main_spacing'] * (self.n_main - 1) + y_start = self.top_padding + self.linewidth_padding + (self.available_height - total_h) / 2 + + main_positions = {} + y = y_start + for main in self.main_branches: + main_positions[main] = y + y += layout['uniform_main_h'] + layout['main_spacing'] + + # Theme position (centered with main branches) + theme_y = y_start + (total_h - layout['theme']['height']) / 2 + + # Draw all elements + self._draw_elements(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) + + # Set strict limits to prevent anything from being drawn outside bounds + # Account for linewidth extension - clip everything strictly + ax.set_xlim(0, self.fig_width) + ax.set_ylim(0, self.fig_height) + # Clip all patches and text to axes limits + ax.set_clip_on(True) + + return fig, ax + + def _draw_elements(self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y): + """Draw all mindmap elements.""" + # Draw theme + theme_rect = patches.Rectangle( + (x_theme, theme_y), layout['theme']['width'], layout['theme']['height'], + linewidth=2.5, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2 + ) + ax.add_patch(theme_rect) + ax.text( + x_theme + layout['theme']['width'] / 2, + theme_y + layout['theme']['height'] / 2, + layout['theme']['text'], + ha='center', va='center', fontsize=layout['theme']['fontsize'], + color=self.colors['text'], fontweight='bold', zorder=3 + ) + + # Draw main branches and sub-branches + for main in self.main_branches: + main_y = main_positions[main] + main_info = layout['main_data'][main] + + # Main branch rectangle + main_rect = patches.Rectangle( + (x_main, main_y), layout['uniform_main_w'], layout['uniform_main_h'], + linewidth=2.5, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2 + ) + ax.add_patch(main_rect) + ax.text( + x_main + layout['uniform_main_w'] / 2, + main_y + layout['uniform_main_h'] / 2, + main_info['text'], + ha='center', va='center', fontsize=main_info['fontsize'], + color=self.colors['text'], fontweight='bold', zorder=3 + ) + + # Connection theme to main + ax.plot( + [x_theme + layout['theme']['width'], x_main], + [theme_y + layout['theme']['height'] / 2, main_y + layout['uniform_main_h'] / 2], + color=self.colors['link'], linewidth=3, alpha=0.6, zorder=1, solid_capstyle='round' + ) + + # Sub-branches + subs = layout['sub_data'].get(main, []) + if subs: + spacing_extra = layout['main_spacing'] * 0.4 + min_sub_y = main_y - spacing_extra + max_sub_y = main_y + layout['uniform_main_h'] + spacing_extra + # Ensure max_sub_y doesn't exceed absolute bottom + max_sub_y = min(max_sub_y, self.absolute_bottom) + available_sub_h = max_sub_y - min_sub_y + + sub_spacing = layout['sub_spacing'] + total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing + + # Adjust spacing if needed + if total_sub_h > available_sub_h: + max_sp = (available_sub_h - layout['uniform_sub_h'] * len(subs)) / max(1, len(subs) - 1) + sub_spacing = max(0.08, min(sub_spacing, max_sp)) + total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing + + # Center on main branch - calculate ideal center position + main_center = main_y + layout['uniform_main_h'] / 2 + ideal_sub_y_start = main_center - total_sub_h / 2 + + # Determine available space boundaries (use the most restrictive) + absolute_min = self.top_padding + self.linewidth_padding + absolute_max = self.absolute_bottom + relative_min = min_sub_y + relative_max = max_sub_y + + # Use the most restrictive boundaries + min_top_y = max(absolute_min, relative_min) + max_bottom_y = min(absolute_max, relative_max) + + # Start with ideal centered position + sub_y_start = ideal_sub_y_start + + # If the entire block fits within available space, use centered position + if ideal_sub_y_start >= min_top_y and ideal_sub_y_start + total_sub_h <= max_bottom_y: + sub_y_start = ideal_sub_y_start + else: + # Block doesn't fit centered - adjust to fit while maintaining centering as much as possible + if ideal_sub_y_start < min_top_y: + # Too high - push down to minimum + sub_y_start = min_top_y + elif ideal_sub_y_start + total_sub_h > max_bottom_y: + # Too low - push up to maximum + sub_y_start = max_bottom_y - total_sub_h + # Ensure we don't go below minimum + if sub_y_start < min_top_y: + sub_y_start = min_top_y + + # Final safety check: ensure we don't exceed absolute bottom + if sub_y_start + total_sub_h > absolute_max: + sub_y_start = max(min_top_y, absolute_max - total_sub_h) + + # Draw sub-branches + sub_y = sub_y_start + # Use pre-calculated sub_linewidth_padding + # Use absolute bottom boundary - no rectangle should exceed this + for sub_info in subs: + # STRICT check: rectangle bottom must not exceed absolute bottom + rect_bottom = sub_y + layout['uniform_sub_h'] + + # Don't draw if rectangle itself would exceed absolute bottom + if rect_bottom > self.absolute_bottom: + break # Don't draw this or any subsequent sub-branches + if sub_y + layout['uniform_sub_h'] > max_sub_y: + break + + # Extra safety check - leave margin for linewidth extension + # Linewidth extends by half outward, so ensure rect_bottom + half_linewidth <= absolute_bottom + half_linewidth_extension = (self.sub_linewidth / 2.0) / 72.0 + if rect_bottom + half_linewidth_extension > self.absolute_bottom: + break + + # Sub-branch rectangle - clip to axes to prevent overflow + sub_rect = patches.Rectangle( + (x_sub, sub_y), layout['uniform_sub_w'], layout['uniform_sub_h'], + linewidth=self.sub_linewidth, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2, + clip_on=True + ) + ax.add_patch(sub_rect) + + # Sub-branch text + ax.text( + x_sub + layout['uniform_sub_w'] / 2, + sub_y + layout['uniform_sub_h'] / 2, + sub_info['text'], + ha='center', va='center', fontsize=sub_info['fontsize'], + color=self.colors['text'], fontweight='bold', zorder=3 + ) + + # Connection main to sub + ax.plot( + [x_main + layout['uniform_main_w'], x_sub], + [main_y + layout['uniform_main_h'] / 2, sub_y + layout['uniform_sub_h'] / 2], + color=self.colors['link'], linewidth=2.5, alpha=0.5, zorder=1, solid_capstyle='round' + ) + + sub_y += layout['uniform_sub_h'] + sub_spacing + + if sub_y + layout['uniform_sub_h'] > max_sub_y: + break + + # Title removed to maximize space for mindmap + + def _adjust_layout_for_overflow(self, layout, issues, iteration): + """Adjust layout to fix overflow issues.""" + # Create temp figure for re-fitting + fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) + ax_temp = fig_temp.add_subplot(111) + ax_temp.axis('off') + + # Reduce font sizes more aggressively + reduction_factor = 0.92 - (iteration * 0.02) # More aggressive each iteration + + # Adjust theme + if issues['theme_overflow']: + current_font = layout['theme']['fontsize'] + new_font = max(9, current_font * reduction_factor) + font, wrapped, _, _ = self._fit_text_main( + ax_temp, self.main_theme, layout['theme']['width'], layout['theme']['height'], + new_font, 9 + ) + layout['theme']['fontsize'] = font + layout['theme']['text'] = wrapped + + # Adjust main branches + if issues['main_overflows']: + for main in issues['main_overflows']: + current_font = layout['main_data'][main]['fontsize'] + new_font = max(8, current_font * reduction_factor) + font, wrapped, _, _ = self._fit_text_main( + ax_temp, main, layout['uniform_main_w'], layout['uniform_main_h'], + new_font, 8 + ) + layout['main_data'][main]['fontsize'] = font + layout['main_data'][main]['text'] = wrapped + + # Adjust for bottom cutoff - reduce vertical spacing or sub-branch height + if issues['bottom_cutoff']: + # Reduce sub-branch height slightly + layout['uniform_sub_h'] *= 0.95 + layout['sub_spacing'] *= 0.95 + # Re-fit all sub-branches + for main in self.main_branches: + subs = layout['sub_data'].get(main, []) + for i, sub_info in enumerate(subs): + sub_text = self.sub_branches[main][i] + font, wrapped, _, _ = self._fit_text_sub( + ax_temp, sub_text, layout['uniform_sub_w'] * 0.94, + layout['uniform_sub_h'] * 0.94, layout['sub_data'][main][i]['fontsize'], 8 + ) + layout['sub_data'][main][i]['fontsize'] = font + layout['sub_data'][main][i]['text'] = wrapped + + plt.close(fig_temp) + return layout + + def save(self, fig: plt.Figure): + """Save the figure as PNG and SVG.""" + filename = self.title.replace(' ', '_') + png_path = os.path.join(self.output_dir, f"{filename}.png") + svg_path = os.path.join(self.output_dir, f"{filename}.svg") + + # Don't use bbox_inches='tight' to ensure we stay within figure bounds + # The axes limits are already set correctly in plot() + fig.savefig(png_path, transparent=True, dpi=300) + fig.savefig(svg_path, transparent=True) + + print(f"Saved: {png_path}") + print(f"Saved: {svg_path}") + + +def plot_mindmap( + mindmap: pd.DataFrame | MindMap, + main_theme: str, + title: str = "Mind Map", + color_scheme: str = "gold", + output_dir: str = "./outputs", + aspect_ratio: float = 8/9 +) -> Tuple[plt.Figure, plt.Axes]: + + """Plot a mindmap from a DataFrame.""" + plotter = MindmapPlotter(mindmap, main_theme, title, color_scheme, output_dir, aspect_ratio) + fig, ax = plotter.plot() + plotter.save(fig) + return fig, ax diff --git a/uv.lock b/uv.lock index 0a200bb..ba80bc4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.12'", @@ -222,6 +222,7 @@ dependencies = [ { name = "bigdata-client" }, { name = "graphviz" }, { name = "json-repair" }, + { name = "matplotlib" }, { name = "openpyxl" }, { name = "pandas" }, { name = "pillow" }, @@ -257,6 +258,7 @@ requires-dist = [ { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.24.0,<2.0.0" }, { name = "graphviz", specifier = ">=0.20.3,<0.21.0" }, { name = "json-repair", specifier = ">=0.44.1" }, + { name = "matplotlib", specifier = ">=3.10.6,<4.0.0" }, { name = "openai", marker = "extra == 'azure'", specifier = ">=1.61.1,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.61.1,<2.0.0" }, { name = "openpyxl", specifier = ">=3.1.5,<4.0.0" }, @@ -452,6 +454,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "coverage" version = "7.10.7" @@ -603,6 +761,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -651,6 +818,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "fonttools" +version = "4.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/70/03e9d89a053caff6ae46053890eba8e4a5665a7c5638279ed4492e6d4b8b/fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28", size = 2810747, upload-time = "2025-09-29T21:10:59.653Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/449ad5aff9670ab0df0f61ee593906b67a36d7e0b4d0cd7fa41ac0325bf5/fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15", size = 2346909, upload-time = "2025-09-29T21:11:02.882Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/e5970aa96c8fad1cb19a9479cc3b7602c0c98d250fcdc06a5da994309c50/fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c", size = 4864572, upload-time = "2025-09-29T21:11:05.096Z" }, + { url = "https://files.pythonhosted.org/packages/ce/20/9b2b4051b6ec6689480787d506b5003f72648f50972a92d04527a456192c/fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea", size = 4794635, upload-time = "2025-09-29T21:11:08.651Z" }, + { url = "https://files.pythonhosted.org/packages/10/52/c791f57347c1be98f8345e3dca4ac483eb97666dd7c47f3059aeffab8b59/fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652", size = 4843878, upload-time = "2025-09-29T21:11:10.893Z" }, + { url = "https://files.pythonhosted.org/packages/69/e9/35c24a8d01644cee8c090a22fad34d5b61d1e0a8ecbc9945ad785ebf2e9e/fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a", size = 4954555, upload-time = "2025-09-29T21:11:13.24Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/fb1e994971be4bdfe3a307de6373ef69a9df83fb66e3faa9c8114893d4cc/fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce", size = 2232019, upload-time = "2025-09-29T21:11:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/40/84/62a19e2bd56f0e9fb347486a5b26376bade4bf6bbba64dda2c103bd08c94/fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038", size = 2276803, upload-time = "2025-09-29T21:11:18.152Z" }, + { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, + { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, + { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, + { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, + { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, + { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -908,6 +1132,189 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/c2/93368d4c9355e8ad1f6d62b804de241939d0796b2a3a73737f665b802808/json_repair-0.50.0-py3-none-any.whl", hash = "sha256:b15da2c42deb43419b182d97dcfde6cd86d0b18ccd18ed1a887104ce85e7a364", size = 25985, upload-time = "2025-08-20T15:01:56.567Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, + { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/87/3932d5778ab4c025db22710b61f49ccaed3956c5cf46ffb2ffa7492b06d9/matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380", size = 8247141, upload-time = "2025-10-09T00:26:06.023Z" }, + { url = "https://files.pythonhosted.org/packages/45/a8/bfed45339160102bce21a44e38a358a1134a5f84c26166de03fb4a53208f/matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d", size = 8107995, upload-time = "2025-10-09T00:26:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3c/5692a2d9a5ba848fda3f48d2b607037df96460b941a59ef236404b39776b/matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297", size = 8680503, upload-time = "2025-10-09T00:26:10.607Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/86ace53c48b05d0e6e9c127b2ace097434901f3e7b93f050791c8243201a/matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42", size = 9514982, upload-time = "2025-10-09T00:26:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/ead71e2824da8f72640a64166d10e62300df4ae4db01a0bac56c5b39fa51/matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7", size = 9566429, upload-time = "2025-10-09T00:26:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/65/7d/954b3067120456f472cce8fdcacaf4a5fcd522478db0c37bb243c7cb59dd/matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3", size = 8108174, upload-time = "2025-10-09T00:26:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, + { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389, upload-time = "2025-10-09T00:26:42.474Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247, upload-time = "2025-10-09T00:26:44.77Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996, upload-time = "2025-10-09T00:26:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153, upload-time = "2025-10-09T00:26:49.07Z" }, + { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093, upload-time = "2025-10-09T00:26:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771, upload-time = "2025-10-09T00:26:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812, upload-time = "2025-10-09T00:26:54.882Z" }, + { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212, upload-time = "2025-10-09T00:26:56.752Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713, upload-time = "2025-10-09T00:26:59.001Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527, upload-time = "2025-10-09T00:27:00.69Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690, upload-time = "2025-10-09T00:27:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732, upload-time = "2025-10-09T00:27:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727, upload-time = "2025-10-09T00:27:06.814Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958, upload-time = "2025-10-09T00:27:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849, upload-time = "2025-10-09T00:27:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225, upload-time = "2025-10-09T00:27:12.241Z" }, + { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708, upload-time = "2025-10-09T00:27:13.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409, upload-time = "2025-10-09T00:27:15.684Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054, upload-time = "2025-10-09T00:27:17.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100, upload-time = "2025-10-09T00:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" }, + { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" }, + { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" }, + { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, + { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6c/a9bcf03e9afb2a873e0a5855f79bce476d1023f26f8212969f2b7504756c/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537", size = 8241204, upload-time = "2025-10-09T00:27:48.806Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fd/0e6f5aa762ed689d9fa8750b08f1932628ffa7ed30e76423c399d19407d2/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657", size = 8104607, upload-time = "2025-10-09T00:27:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/21c9439d698fac5f0de8fc68b2405b738ed1f00e1279c76f2d9aa5521ead/matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b", size = 8682257, upload-time = "2025-10-09T00:27:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, +] + [[package]] name = "msal" version = "1.33.0" @@ -1688,6 +2095,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + [[package]] name = "pytest" version = "8.4.1" From 2580cc03ed7ad4656e3cd1e37be9d6000b8c11ad Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Tue, 18 Nov 2025 13:40:23 +0000 Subject: [PATCH 04/14] fix query for grounding --- src/bigdata_research_tools/mindmap/mindmap_generator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index dc4de88..7f4a4ec 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -657,7 +657,11 @@ def _run_and_collate_search( else: keywords = None - queries = [Similarity(sentence)&keywords&entities if keywords or entities else Similarity(sentence) for sentence in search_list] + queries = [Similarity(sentence) for sentence in search_list] + if entities: + queries = [query&entities for query in queries] + if keywords: + queries = [query&keywords for query in queries] all_results = run_search(queries=queries, date_ranges = date_range, From c7e046167734504ab98f9137cc14a9d48106d581 Mon Sep 17 00:00:00 2001 From: jaldana Date: Tue, 18 Nov 2025 15:40:11 +0100 Subject: [PATCH 05/14] Formatting and linting --- examples/grounded_mindmaps.py | 194 ++-- src/bigdata_research_tools/llm/base.py | 4 +- src/bigdata_research_tools/llm/openai.py | 14 +- .../mindmap/__init__.py | 6 +- src/bigdata_research_tools/mindmap/mindmap.py | 33 +- .../mindmap/mindmap_generator.py | 665 ++++++++----- .../mindmap/mindmap_utils.py | 49 +- src/bigdata_research_tools/search/search.py | 13 +- .../visuals/mindmap_visuals.py | 882 +++++++++++------- .../workflows/risk_analyzer.py | 8 +- .../workflows/thematic_screener.py | 2 +- 11 files changed, 1164 insertions(+), 706 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index 52097dd..b3750bc 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -1,8 +1,7 @@ import logging -from bigdata_client import Bigdata -from bigdata_client.models.search import DocumentType from dotenv import load_dotenv + from bigdata_research_tools.mindmap.mindmap import MindMap from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap @@ -16,87 +15,167 @@ ) logger = logging.getLogger(__name__) -def test_one_shot_mindmap(main_theme, focus, map_type, instructions, llm_base_config: str = "openai::gpt-4o-mini") -> MindMap: + +def test_one_shot_mindmap( + main_theme, + focus, + map_type, + instructions, + llm_base_config: str = "openai::gpt-4o-mini", +) -> MindMap: """Test one-shot mind map generation with base LLM.""" logger.info("=" * 60) logger.info("TEST 1: One-Shot Mind Map Generation with Base LLM") logger.info("=" * 60) - mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config,) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + ) mindmap = mindmap_generator.generate_one_shot( - instructions=instructions, - focus=focus, - main_theme=main_theme, - map_type = map_type, - allow_grounding=False, -) - logger.info("Results: %s", mindmap['mindmap_text']) + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + allow_grounding=False, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) return mindmap["mindmap_df"], mindmap["mindmap_json"] - - -def test_refined_mindmap(main_theme, focus, map_type, instructions, base_mindmap: str, llm_base_config: str = "openai::o3-mini") -> MindMap: + + +def test_refined_mindmap( + main_theme, + focus, + map_type, + instructions, + base_mindmap: str, + llm_base_config: str = "openai::o3-mini", +) -> MindMap: """Test refined mindmap generation with reasoning LLM sent in the base config.""" logger.info("=" * 60) logger.info("TEST 2: Refined MindMap Generation with Reasoning LLM in Base Config") logger.info("=" * 60) - mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config,) - mindmap = mindmap_generator.generate_refined(focus = focus, - main_theme = main_theme, - initial_mindmap = base_mindmap, - grounding_method = "tool_call", - output_dir = "./refined_mindmaps", - filename = "refined_mindmap.json", - map_type = map_type, - instructions = instructions, - ) - logger.info("Results: %s", mindmap['mindmap_text']) - -def test_refined_mindmap2(main_theme, focus, map_type, instructions, base_mindmap: str, llm_base_config: str | None = None, llm_reasoning_config: str = "openai::o3-mini") -> MindMap: + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + ) + mindmap = mindmap_generator.generate_refined( + focus=focus, + main_theme=main_theme, + initial_mindmap=base_mindmap, + grounding_method="tool_call", + output_dir="./refined_mindmaps", + filename="refined_mindmap.json", + map_type=map_type, + instructions=instructions, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) + + +def test_refined_mindmap2( + main_theme, + focus, + map_type, + instructions, + base_mindmap: str, + llm_base_config: str | None = None, + llm_reasoning_config: str = "openai::o3-mini", +) -> MindMap: """Test refined mindmap generation with reasoning LLM sent in the reasoning config.""" logger.info("=" * 60) - logger.info("TEST 3: Refined MindMap Generation with Reasoning LLM in Reasoning Config") + logger.info( + "TEST 3: Refined MindMap Generation with Reasoning LLM in Reasoning Config" + ) logger.info("=" * 60) - mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config) - mindmap = mindmap_generator.generate_refined(focus = focus, - main_theme = main_theme, - initial_mindmap = base_mindmap, - grounding_method = "tool_call", - output_dir = "./refined_mindmaps", - filename = "refined_mindmap.json", - map_type = map_type, - instructions = instructions, - ) - logger.info("Results: %s", mindmap['mindmap_text']) - -def test_dynamic_mindmap(main_theme, focus, map_type, instructions, llm_base_config: str = "openai::gpt-4o-mini", llm_reasoning_config: str = "openai::o3-mini") -> MindMap: + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + llm_model_config_reasoning=llm_reasoning_config, + ) + mindmap = mindmap_generator.generate_refined( + focus=focus, + main_theme=main_theme, + initial_mindmap=base_mindmap, + grounding_method="tool_call", + output_dir="./refined_mindmaps", + filename="refined_mindmap.json", + map_type=map_type, + instructions=instructions, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) + + +def test_dynamic_mindmap( + main_theme, + focus, + map_type, + instructions, + llm_base_config: str = "openai::gpt-4o-mini", + llm_reasoning_config: str = "openai::o3-mini", +) -> MindMap: """Test dynamic mindmap generation with two LLMs.""" logger.info("=" * 60) logger.info("TEST 4: Dynamic MindMap Generation with Two LLMs") logger.info("=" * 60) - mindmap_generator = MindMapGenerator(llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + llm_model_config_reasoning=llm_reasoning_config, + ) mindmap = mindmap_generator.generate_dynamic( - instructions = instructions, - focus = focus, - main_theme = main_theme, - month_intervals = [["2025-10-01", "2025-10-31"], ["2025-11-01", "2025-11-30"], ["2025-12-01", "2025-12-31"]], - month_names = ['October_2025', 'November_2025', 'December_2025'],) - logger.info("Results: %s", mindmap['base_mindmap']) - logger.info("Results: %s", mindmap['October_2025']) + instructions=instructions, + focus=focus, + main_theme=main_theme, + month_intervals=[ + ["2025-10-01", "2025-10-31"], + ["2025-11-01", "2025-11-30"], + ["2025-12-01", "2025-12-31"], + ], + month_names=["October_2025", "November_2025", "December_2025"], + ) + logger.info("Results: %s", mindmap["base_mindmap"]) + logger.info("Results: %s", mindmap["October_2025"]) logger.info("") -def main(MAIN_THEME = "Political Change in Japan.", - INSTRUCTIONS = 'Create a mindmap according to a given risk scenario. Map by risk type for any industry and assess short term impact only.', - FOCUS = "Provide a detailed taxonomy of risks related to changes in the Japanese political landscape. Evaluate how the resignation of the Prime Minister and the pre-election of Sanae Takaichi will affect companies, their strategy and operations. Take into consideration their increased conservative stance on immigration, energy, and trade. Add any other risk areas that may arise from these political changes. The mind map should be as comprehensive as possible and cover all major risk areas.", - map_type = 'risk'): + +def main( + MAIN_THEME="Political Change in Japan.", + INSTRUCTIONS="Create a mindmap according to a given risk scenario. Map by risk type for any industry and assess short term impact only.", + FOCUS="Provide a detailed taxonomy of risks related to changes in the Japanese political landscape. Evaluate how the resignation of the Prime Minister and the pre-election of Sanae Takaichi will affect companies, their strategy and operations. Take into consideration their increased conservative stance on immigration, energy, and trade. Add any other risk areas that may arise from these political changes. The mind map should be as comprehensive as possible and cover all major risk areas.", + map_type="risk", +): """Run all tests.""" logger.info("Testing Grounded MindMap Generation") logger.info("=" * 60) try: - df_mindmap, base_mindmap = test_one_shot_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini") + df_mindmap, base_mindmap = test_one_shot_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + llm_base_config="openai::gpt-4o-mini", + ) plot_mindmap(df_mindmap, MAIN_THEME) - test_refined_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") - test_refined_mindmap2(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, base_mindmap, llm_base_config="openai::o3-mini") - test_dynamic_mindmap(MAIN_THEME, FOCUS, map_type, INSTRUCTIONS, llm_base_config="openai::gpt-4o-mini", llm_reasoning_config="openai::o3-mini") + test_refined_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + base_mindmap, + llm_base_config="openai::o3-mini", + ) + test_refined_mindmap2( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + base_mindmap, + llm_base_config="openai::o3-mini", + ) + test_dynamic_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + llm_base_config="openai::gpt-4o-mini", + llm_reasoning_config="openai::o3-mini", + ) logger.info("=" * 60) logger.info("All tests completed successfully") @@ -105,5 +184,6 @@ def main(MAIN_THEME = "Political Change in Japan.", logger.error("Error during testing: %s", e) raise + if __name__ == "__main__": main() diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 3dc72fe..413f420 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -350,9 +350,7 @@ def get_tools_response( - arguments (list[dict]): List of arguments for each function - text (str): The text content of the message, if any. """ - return self.provider.get_tools_response( - chat_history, tools, **kwargs - ) + return self.provider.get_tools_response(chat_history, tools, **kwargs) class NotInitializedLLMProviderError(Exception): diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 6e4d0f7..7ec6fe6 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -197,18 +197,22 @@ def get_tools_response( ) message = response.choices[0].message output = { - "id" : [], + "id": [], "func_names": [], "arguments": [], "text": message.content, - "tool_calls": {}} - + "tool_calls": {}, + } + if function_calls := message.tool_calls if message.tool_calls else None: output = { - "id" : [f.id for f in function_calls], + "id": [f.id for f in function_calls], "func_names": [f.function.name for f in function_calls], "arguments": [loads(f.function.arguments) for f in function_calls], - "tool_calls": response.model_dump().get("choices", [])[0].get("message", {}).get("tool_calls", []) + "tool_calls": response.model_dump() + .get("choices", [])[0] + .get("message", {}) + .get("tool_calls", []), } return output diff --git a/src/bigdata_research_tools/mindmap/__init__.py b/src/bigdata_research_tools/mindmap/__init__.py index 10b1154..2e8809e 100644 --- a/src/bigdata_research_tools/mindmap/__init__.py +++ b/src/bigdata_research_tools/mindmap/__init__.py @@ -1,4 +1,8 @@ -from bigdata_research_tools.mindmap.mindmap import MindMap, generate_theme_tree, generate_risk_tree +from bigdata_research_tools.mindmap.mindmap import ( + MindMap, + generate_risk_tree, + generate_theme_tree, +) from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator __all__ = ["MindMap", "MindMapGenerator", "generate_theme_tree", "generate_risk_tree"] diff --git a/src/bigdata_research_tools/mindmap/mindmap.py b/src/bigdata_research_tools/mindmap/mindmap.py index 2846a9d..f466196 100644 --- a/src/bigdata_research_tools/mindmap/mindmap.py +++ b/src/bigdata_research_tools/mindmap/mindmap.py @@ -199,7 +199,7 @@ def visualize(self, engine: str = "graphviz") -> None: f"Unsupported engine '{engine}'. " f"Supported engines are 'graphviz', 'plotly', and 'matplotlib'." ) - + def _visualize_matplotlib(self): """ Auxiliary function to visualize the tree using Matplotlib. @@ -208,11 +208,11 @@ def _visualize_matplotlib(self): A Matplotlib Plot rendering the mindmap. """ import matplotlib - matplotlib.use('Agg') # Use non-interactive backend + + matplotlib.use("Agg") # Use non-interactive backend from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap - - plot_mindmap(self.to_dataframe(), main_theme=self.label) + plot_mindmap(self.to_dataframe(), main_theme=self.label) def _visualize_graphviz(self) -> graphviz.Digraph: """ @@ -351,29 +351,34 @@ def to_rows(self, parent_label=None): Flatten tree to rows for DataFrame: each row is (Parent, Label, Node, Summary) """ rows = [] - rows.append({ - "Parent": parent_label, - "Label": self.label, - "Node": self.node, - "Summary": self.summary - }) + rows.append( + { + "Parent": parent_label, + "Label": self.label, + "Node": self.node, + "Summary": self.summary, + } + ) for child in self.children: rows.extend(child.to_rows(parent_label=self.label)) return rows def to_dataframe(self, leaves_only=False): import pandas as pd + rows = self.to_rows(parent_label=None) # Exclude rows where Parent is None or Parent == self.label (root node) filtered = [row for row in rows if row["Parent"] not in (None, self.label)] if leaves_only: # Only keep rows that are leaves (i.e., have no children) - leaf_labels = {row["Label"] for row in filtered} - filtered = [row for row in filtered if row["Label"] not in {r["Parent"] for r in filtered}] + filtered = [ + row + for row in filtered + if row["Label"] not in {r["Parent"] for r in filtered} + ] return pd.DataFrame(filtered) - + def to_json(self): - return json.dumps(self._to_dict(), indent=2) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 7f4a4ec..7c18f2f 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -1,8 +1,33 @@ -from typing import Any, List, Dict, Optional, Tuple -from bigdata_research_tools.llm.base import LLMConfig +import ast +import json +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from logging import Logger, getLogger +from typing import Any, Dict, List, Optional, Tuple + +from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange +from bigdata_client.models.search import DocumentType, SortBy +from bigdata_client.query import ( + Any as BigdataAny, +) +from bigdata_client.query import ( + Keyword, + Similarity, +) +from tqdm import tqdm + +from bigdata_research_tools.client import bigdata_connection from bigdata_research_tools.llm import LLMEngine +from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap import MindMap, get_default_tree_config +from bigdata_research_tools.mindmap.mindmap_utils import ( + format_mindmap_to_dataframe, + load_results_from_file, + prompts_dict, + save_results_to_file, +) -from bigdata_research_tools.search.query_builder import build_batched_query # from bigdata_research_tools.search.query_builder import ( # EntitiesToSearch, # build_batched_query, @@ -10,55 +35,40 @@ # ) # cannot use query builder because it is to error-prone to build EntitiesToSearch based on the LLM output from bigdata_research_tools.search.search import run_search -from bigdata_research_tools.client import bigdata_connection -from bigdata_client.query import ( - Any, - Keyword, - Similarity, -) -from bigdata_research_tools.mindmap.mindmap_utils import format_mindmap_to_dataframe, save_results_to_file, load_results_from_file, prompts_dict -import os -import json -import re -import json -import ast -from concurrent.futures import ThreadPoolExecutor, as_completed -from tqdm import tqdm -from bigdata_research_tools.mindmap.mindmap import MindMap, get_default_tree_config -from logging import Logger, getLogger -from bigdata_client.models.search import DocumentType, SortBy -from bigdata_client.daterange import RollingDateRange, AbsoluteDateRange logger: Logger = getLogger(__name__) -bigdata_tool_description = [{ - "type": "function", - "function": { - "name": "bigdata_search", - "description": "Run a semantic similarity search on news content using Bigdata API.", - "parameters": { - "type": "object", - "properties": { - "search_list": { - "type": "array", - "items": {"type": "string"}, - "description": "The list of strings containing various detailed sentences to search in News documents.", - }, - "entities_list": { - "type": "array", - "items": {"type": "string"}, - "description": "The list of entities (People, Places or Organizations) to focus the search on. They will be added as search context with an OR logic.", - }, - "keywords_list": { - "type": "array", - "items": {"type": "string"}, - "description": "The list of keywords (one or two words defining topics or concepts) to focus the search on. They will be added as search context with an OR logic.", - } - }, - "required": ["search_list", "entities_list", "keywords_list"] - } - } - }] +bigdata_tool_description = [ + { + "type": "function", + "function": { + "name": "bigdata_search", + "description": "Run a semantic similarity search on news content using Bigdata API.", + "parameters": { + "type": "object", + "properties": { + "search_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of strings containing various detailed sentences to search in News documents.", + }, + "entities_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of entities (People, Places or Organizations) to focus the search on. They will be added as search context with an OR logic.", + }, + "keywords_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of keywords (one or two words defining topics or concepts) to focus the search on. They will be added as search context with an OR logic.", + }, + }, + "required": ["search_list", "entities_list", "keywords_list"], + }, + }, + } +] + class MindMapGenerator: """ @@ -70,17 +80,22 @@ class MindMapGenerator: - Dynamic mind map evolution over time intervals (each step refines previous map with new search context) """ - def __init__(self, - llm_model_config_base: LLMConfig | dict | str = "openai::gpt-4o-mini", - llm_model_config_reasoning: Optional[LLMConfig | dict | str] = None, - ): + def __init__( + self, + llm_model_config_base: LLMConfig | dict | str = "openai::gpt-4o-mini", + llm_model_config_reasoning: Optional[LLMConfig | dict | str] = None, + ): """ Args: llm_client: Handles LLM chat and tool-calling. """ self.bigdata_connection = bigdata_connection() - - llm_model_config_reasoning = llm_model_config_reasoning if llm_model_config_reasoning else llm_model_config_base + + llm_model_config_reasoning = ( + llm_model_config_reasoning + if llm_model_config_reasoning + else llm_model_config_base + ) if isinstance(llm_model_config_base, dict): self.llm_model_config_base = LLMConfig(**llm_model_config_base) @@ -90,27 +105,35 @@ def __init__(self, if isinstance(llm_model_config_reasoning, dict): self.llm_model_config_reasoning = LLMConfig(**llm_model_config_reasoning) elif isinstance(llm_model_config_reasoning, str): - self.llm_model_config_reasoning = get_default_tree_config(llm_model_config_reasoning) - + self.llm_model_config_reasoning = get_default_tree_config( + llm_model_config_reasoning + ) + print(self.llm_model_config_base) - self.llm_base = LLMEngine(model=self.llm_model_config_base.model, **self.llm_model_config_base.connection_config) + self.llm_base = LLMEngine( + model=self.llm_model_config_base.model, + **self.llm_model_config_base.connection_config, + ) print(self.llm_model_config_reasoning) - self.llm_reasoning = LLMEngine(model=self.llm_model_config_reasoning.model, **self.llm_model_config_reasoning.connection_config) + self.llm_reasoning = LLMEngine( + model=self.llm_model_config_reasoning.model, + **self.llm_model_config_reasoning.connection_config, + ) def _parse_llm_to_themetree(self, mindmap_text: str) -> MindMap: """ Parse LLM output (expected to be a valid JSON object) into a MindMap. Strictly enforce JSON/dict structure, required fields, and allowed keys. If parsing or validation fails, raises an error with details. """ - import collections.abc + text = mindmap_text.strip() # Remove code block markers and language tags (minimal cleaning) - text = re.sub(r'^```[a-zA-Z]*\s*', '', text) - text = re.sub(r'```$', '', text) + text = re.sub(r"^```[a-zA-Z]*\s*", "", text) + text = re.sub(r"```$", "", text) # Remove accidental language tags at the start (e.g., "json\n{") - text = re.sub(r'^[a-zA-Z]+\s*\n*{', '{', text) + text = re.sub(r"^[a-zA-Z]+\s*\n*{", "{", text) # Remove any prefix before the first { or [ - text = re.sub(r'^[^({\[]*({|\[)', r'\1', text, flags=re.DOTALL) + text = re.sub(r"^[^({\[]*({|\[)", r"\1", text, flags=re.DOTALL) # Try JSON, then ast.literal_eval try: tree_dict = json.loads(text) @@ -118,23 +141,32 @@ def _parse_llm_to_themetree(self, mindmap_text: str) -> MindMap: try: tree_dict = ast.literal_eval(text) except Exception as e: - raise ValueError(f"Failed to parse LLM output as JSON or Python dict.\nRaw output:\n{mindmap_text}\nCLEANED OUTPUT:\n{text}\nError: {e}") + raise ValueError( + f"Failed to parse LLM output as JSON or Python dict.\nRaw output:\n{mindmap_text}\nCLEANED OUTPUT:\n{text}\nError: {e}" + ) # --- Strict validation of required fields and allowed keys --- allowed_keys = {"label", "node", "summary", "children"} + def validate_node(node, path="root"): if not isinstance(node, dict): raise ValueError(f"Node at {path} is not a dict: {node}") # Check for illegal keys illegal_keys = set(node.keys()) - allowed_keys if illegal_keys: - raise ValueError(f"Illegal key(s) {illegal_keys} at {path}. Node: {node}") + raise ValueError( + f"Illegal key(s) {illegal_keys} at {path}. Node: {node}" + ) # Check for required fields for key in allowed_keys: if key not in node or node[key] is None: - raise ValueError(f"Missing or null required field '{key}' at {path}. Node: {node}") + raise ValueError( + f"Missing or null required field '{key}' at {path}. Node: {node}" + ) if not isinstance(node["children"], list): - raise ValueError(f"'children' field at {path} is not a list. Node: {node}") + raise ValueError( + f"'children' field at {path} is not a list. Node: {node}" + ) for idx, child in enumerate(node["children"]): validate_node(child, path=f"{path} -> children[{idx}]") @@ -146,15 +178,20 @@ def dict_keys_to_lowercase(d): return [dict_keys_to_lowercase(i) for i in d] else: return d + tree_dict = dict_keys_to_lowercase(tree_dict) try: validate_node(tree_dict) except Exception as e: - raise ValueError(f"Mind map structure validation failed: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}") + raise ValueError( + f"Mind map structure validation failed: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}" + ) try: theme_tree = MindMap.from_dict(tree_dict) except Exception as e: - raise ValueError(f"Failed to build ThemeTree from dict: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}") + raise ValueError( + f"Failed to build ThemeTree from dict: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}" + ) return theme_tree def _themetree_to_dataframe(self, theme_tree: MindMap): @@ -164,103 +201,149 @@ def _themetree_to_dataframe(self, theme_tree: MindMap): try: df = theme_tree.to_dataframe() except Exception as e: - raise ValueError(f"Failed to convert ThemeTree to DataFrame: {e}\nThemeTree:\n{theme_tree}") + raise ValueError( + f"Failed to convert ThemeTree to DataFrame: {e}\nThemeTree:\n{theme_tree}" + ) return df - - def compose_base_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str]) -> list: + + def compose_base_message( + self, main_theme: str, focus: str, map_type: str, instructions: Optional[str] + ) -> list: # Explicit, step-by-step prompt (robust, as in working repo, minus Keywords) - enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] messages = [ - {"role": "system", "content": f"{instructions} {focus}\n{enforce_structure}"}, - {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)} + { + "role": "system", + "content": f"{instructions} {focus}\n{enforce_structure}", + }, + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, ] return messages - - def compose_tool_call_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], initial_mindmap: Optional[str]) -> list: - enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + + def compose_tool_call_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + initial_mindmap: Optional[str], + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." if initial_mindmap: + tool_prompt += f"Starting from the following mind map:\n{initial_mindmap}" - tool_prompt+=f"Starting from the following mind map:\n{initial_mindmap}" - - tool_prompt+=f"\nReturn a list of searches you would like to perform to enhance it.\n{enforce_structure}" + tool_prompt += f"\nReturn a list of searches you would like to perform to enhance it.\n{enforce_structure}" messages = [ {"role": "system", "content": tool_prompt}, - {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)} + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, ] return messages - def send_tool_call(self, messages: list, llm_client:LLMEngine, llm_kwargs: dict) -> list: - - llm_kwargs.update({"tool_choice": {"type": "function", "function": {"name": "bigdata_search"}}}) + def send_tool_call( + self, messages: list, llm_client: LLMEngine, llm_kwargs: dict + ) -> list: + llm_kwargs.update( + { + "tool_choice": { + "type": "function", + "function": {"name": "bigdata_search"}, + } + } + ) response_dict = llm_client.get_tools_response( - messages,tools=bigdata_tool_description, **llm_kwargs) + messages, tools=bigdata_tool_description, **llm_kwargs + ) try: if response_dict["tool_calls"] is not None: - tool_call_id = response_dict["id"][0] arguments = response_dict["arguments"][0] search_list = arguments.get("search_list", []) entities_list = arguments.get("entities_list", []) keywords_list = arguments.get("keywords_list", []) - return tool_call_id, response_dict["tool_calls"], search_list, entities_list, keywords_list + return ( + tool_call_id, + response_dict["tool_calls"], + search_list, + entities_list, + keywords_list, + ) else: print("No tool call found in the response.") - + return None, None, response_dict["text"], None, None except Exception as e: raise RuntimeError(f"Failed to parse OpenAI tool call response: {e}") - def compose_final_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], tool_calls, tool_call_id, context) -> list: - enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + def compose_final_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + tool_calls, + tool_call_id, + context, + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] final_message = [ - {"role": "system", "content": f"{instructions} {focus}. IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant. \n{enforce_structure}"}, - {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)}, - { - "role": "assistant", - "content": None, - "tool_calls": tool_calls - }, - { - "role": "tool", - "tool_call_id": tool_call_id, - "content": context - } - ] - + { + "role": "system", + "content": f"{instructions} {focus}. IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant. \n{enforce_structure}", + }, + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + {"role": "tool", "tool_call_id": tool_call_id, "content": context}, + ] + return final_message - - def compose_refinement_message(self, main_theme: str, focus: str, map_type: str, instructions: Optional[str], initial_mindmap: str, context: str, tool_calls, tool_call_id) -> list: - enforce_structure = prompts_dict[map_type]['enforce_structure_string'] + def compose_refinement_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + initial_mindmap: str, + context: str, + tool_calls, + tool_call_id, + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] refine_prompt = ( - f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus} " - "Based on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text." - "IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant." - f"{enforce_structure}." - - ) + f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus} " + "Based on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text." + "IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant." + f"{enforce_structure}." + ) refinement_messages = [ - {"role": "system", "content": refine_prompt}, - {"role": "user", "content": initial_mindmap}, - { - "role": "assistant", - "content": None, - "tool_calls": tool_calls - }, - { - "role": "tool", - "tool_call_id": tool_call_id, - "content": context - } - ] - + {"role": "system", "content": refine_prompt}, + {"role": "user", "content": initial_mindmap}, + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + {"role": "tool", "tool_call_id": tool_call_id, "content": context}, + ] + return refinement_messages def generate_one_shot( @@ -278,21 +361,39 @@ def generate_one_shot( If allow_grounding is True, use the specified grounding_method ("tool_call" or "chat"). Optionally log intermediate steps to disk. """ - - + messages = self.compose_base_message(main_theme, focus, map_type, instructions) - llm_kwargs = self.llm_model_config_base.get_llm_kwargs(remove_max_tokens=True, remove_timeout=True) + llm_kwargs = self.llm_model_config_base.get_llm_kwargs( + remove_max_tokens=True, remove_timeout=True + ) if allow_grounding: if grounding_method == "tool_call": - messages.append({"role": "user", "content": "You can use news search to find relevant information about the topic. " - "Use the Bigdata API to search for news articles related to the topic and use them to inform your response. You will need to specify a list of sentences, a list of entities, and a list of keywords."}) - tool_call_id, tool_calls, search_list, entities_list, keywords_list = self.send_tool_call(messages,self.llm_base, llm_kwargs) - + messages.append( + { + "role": "user", + "content": "You can use news search to find relevant information about the topic. " + "Use the Bigdata API to search for news articles related to the topic and use them to inform your response. You will need to specify a list of sentences, a list of entities, and a list of keywords.", + } + ) + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_base, llm_kwargs) + ) + if search_list and isinstance(search_list, list): - context = self._run_and_collate_search(search_list, entities_list, keywords_list, date_range=date_range) - - final_messages = self.compose_final_message(main_theme, focus, map_type, instructions, tool_calls, tool_call_id, context) + context = self._run_and_collate_search( + search_list, entities_list, keywords_list, date_range=date_range + ) + + final_messages = self.compose_final_message( + main_theme, + focus, + map_type, + instructions, + tool_calls, + tool_call_id, + context, + ) mindmap_text = self.llm_base.get_response(final_messages) @@ -301,24 +402,26 @@ def generate_one_shot( return { "mindmap_text": mindmap_text, "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), ##where does this come from? + "mindmap_json": theme_tree.to_json(), ##where does this come from? "grounded": True, "search_queries": search_list, - "search_context": context + "search_context": context, } else: - #decide if this fallback should be simplified + # decide if this fallback should be simplified mindmap_text = search_list if isinstance(search_list, str) else "" - theme_tree = self._parse_llm_to_themetree(mindmap_text) ## check if correct + theme_tree = self._parse_llm_to_themetree( + mindmap_text + ) ## check if correct df = format_mindmap_to_dataframe(mindmap_text) return { "mindmap_text": mindmap_text, "mindmap_df": df, "mindmap_json": theme_tree.to_json(), - "grounded": False + "grounded": False, } else: - #decide if this fallback should be simplified + # decide if this fallback should be simplified messages[0]["content"] += ( " You may request news search to ground your mind map. " "If you want to search, return a list of queries." @@ -329,11 +432,19 @@ def generate_one_shot( if queries: context = self._run_and_collate_search(queries, [], []) - + followup_messages = [ {"role": "system", "content": f"{instructions} {focus}"}, - {"role": "user", "content": prompts_dict[map_type]['user_prompt_message'].format(main_theme=main_theme)}, - {"role": "assistant", "content": "News search results:\n" + context} + { + "role": "user", + "content": prompts_dict[map_type][ + "user_prompt_message" + ].format(main_theme=main_theme), + }, + { + "role": "assistant", + "content": "News search results:\n" + context, + }, ] mindmap_text = self.llm_base.get_response(followup_messages) @@ -344,11 +455,11 @@ def generate_one_shot( "mindmap_json": theme_tree.to_json(), "grounded": True, "search_queries": queries, - "search_context": context + "search_context": context, } # Default: just generate mind map mindmap_text = self.llm_base.get_response(messages) - + theme_tree = self._parse_llm_to_themetree(mindmap_text) df = self._themetree_to_dataframe(theme_tree) return { @@ -356,7 +467,7 @@ def generate_one_shot( "mindmap_tree": theme_tree, "mindmap_json": theme_tree.to_json(), "mindmap_df": df, - "grounded": False + "grounded": False, } def generate_refined( @@ -365,32 +476,52 @@ def generate_refined( main_theme: str, initial_mindmap: str, grounding_method: str = "tool_call", - output_dir:str = "./refined_mindmaps", - filename:str = "refined_mindmap.json", + output_dir: str = "./refined_mindmaps", + filename: str = "refined_mindmap.json", map_type: str = "risk", instructions: Optional[str] = None, search_scope: Optional[Any] = None, sortby: Optional[Any] = None, date_range: Optional[Any] = None, chunk_limit: Optional[int] = 20, - **llm_kwargs + **llm_kwargs, ) -> Dict[str, Any]: """ Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. Optionally log intermediate steps to disk. """ - messages = self.compose_tool_call_message(main_theme, focus, map_type, instructions, initial_mindmap) - llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs(remove_max_tokens=True, remove_timeout=True) + messages = self.compose_tool_call_message( + main_theme, focus, map_type, instructions, initial_mindmap + ) + llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs( + remove_max_tokens=True, remove_timeout=True + ) if grounding_method == "tool_call": - tool_call_id, tool_calls, search_list, entities_list, keywords_list = self.send_tool_call( - messages,self.llm_reasoning, llm_kwargs=llm_kwargs) - + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_reasoning, llm_kwargs=llm_kwargs) + ) + if search_list and isinstance(search_list, list): context = self._run_and_collate_search( - search_list, entities_list, keywords_list, search_scope, sortby, date_range, chunk_limit + search_list, + entities_list, + keywords_list, + search_scope, + sortby, + date_range, + chunk_limit, + ) + + refinement_messages = self.compose_refinement_message( + main_theme, + focus, + map_type, + instructions, + initial_mindmap, + context, + tool_calls, + tool_call_id, ) - - refinement_messages = self.compose_refinement_message(main_theme, focus, map_type, instructions, initial_mindmap, context, tool_calls, tool_call_id) mindmap_text = self.llm_reasoning.get_response(refinement_messages) theme_tree = self._parse_llm_to_themetree(mindmap_text) @@ -400,7 +531,7 @@ def generate_refined( "mindmap_df": df, "mindmap_json": theme_tree.to_json(), "search_queries": search_list, - "search_context": context + "search_context": context, } save_results_to_file(result_dict, output_dir, filename) return result_dict @@ -412,7 +543,7 @@ def generate_refined( "mindmap_df": df, "mindmap_json": theme_tree.to_json(), "search_queries": [], - "search_context": "" + "search_context": "", } save_results_to_file(result_dict, output_dir, filename) return result_dict @@ -424,7 +555,16 @@ def generate_refined( search_queries, [], [], search_scope, sortby, date_range, chunk_limit ) - refinement_messages = self.compose_refinement_message(main_theme, focus, map_type, instructions, initial_mindmap, context, tool_calls, tool_call_id) + refinement_messages = self.compose_refinement_message( + main_theme, + focus, + map_type, + instructions, + initial_mindmap, + context, + tool_calls, + tool_call_id, + ) mindmap_text = self.llm_reasoning.get_response(refinement_messages) theme_tree = self._parse_llm_to_themetree(mindmap_text) @@ -434,26 +574,29 @@ def generate_refined( "mindmap_df": df, "mindmap_json": theme_tree.to_json(), "search_queries": search_queries, - "search_context": context + "search_context": context, } save_results_to_file(result_dict, output_dir, filename) return result_dict - - def generate_or_load_refined(self, instructions: str, - focus: str, - main_theme: str, - map_type: str, - initial_mindmap: str, - llm_model: str = "o3-mini", - reasoning_effort: str = "high", - search_scope: Any = None, - sortby: Any = None, - date_range: Any = None, - chunk_limit: int = 20, - grounding_method: str = "tool_call", - output_dir:str = "./bootstrapped_mindmaps", - filename: str = "refined_mindmap", - i: int = 0): + + def generate_or_load_refined( + self, + instructions: str, + focus: str, + main_theme: str, + map_type: str, + initial_mindmap: str, + llm_model: str = "o3-mini", + reasoning_effort: str = "high", + search_scope: Any = None, + sortby: Any = None, + date_range: Any = None, + chunk_limit: int = 20, + grounding_method: str = "tool_call", + output_dir: str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + i: int = 0, + ): if f"{filename}_{i}.json" in os.listdir(output_dir): result = load_results_from_file(output_dir, f"{filename}_{i}.json") print(f"Loaded existing result for {filename}_{i}.json") @@ -469,9 +612,9 @@ def generate_or_load_refined(self, instructions: str, grounding_method=grounding_method, date_range=date_range, output_dir=output_dir, - filename = f"{filename}_{i}.json" + filename=f"{filename}_{i}.json", ) - #save_results_to_file(result, output_dir, ) + # save_results_to_file(result, output_dir, ) except Exception as e: print(e) result = self.generate_refined( @@ -484,42 +627,45 @@ def generate_or_load_refined(self, instructions: str, grounding_method=grounding_method, date_range=date_range, output_dir=output_dir, - filename = f"{filename}_{i}.json" + filename=f"{filename}_{i}.json", ) - #save_results_to_file(result, output_dir, f"{filename}_{i}.json") + # save_results_to_file(result, output_dir, f"{filename}_{i}.json") return result - def bootstrap_refined(self, instructions: str, - focus: str, - main_theme: str, - map_type: str, - initial_mindmap: str, - search_scope: Any = None, - sortby: Any = None, - date_range: Any = None, - chunk_limit: int = 20, - grounding_method: str = "tool_call", - output_dir: str = "./bootstrapped_mindmaps", - filename: str = "refined_mindmap", - n_elements: int = 50, - max_workers: int = 10): + def bootstrap_refined( + self, + instructions: str, + focus: str, + main_theme: str, + map_type: str, + initial_mindmap: str, + search_scope: Any = None, + sortby: Any = None, + date_range: Any = None, + chunk_limit: int = 20, + grounding_method: str = "tool_call", + output_dir: str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + n_elements: int = 50, + max_workers: int = 10, + ): """ Generate multiple refined mindmaps in parallel using ThreadPoolExecutor. - + Generates n_elements mindmaps by calling generate_or_load_refined for each index. Uses a thread pool to parallelize the generation process for better efficiency. Each mindmap is saved with an index suffix to the output_dir. - + Returns a list of all generated mindmap results. """ # Create output directory if it doesn't exist os.makedirs(output_dir, exist_ok=True) - + refined_results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: # Create a mapping of futures to their corresponding indices future_to_index = {} - + # Submit all tasks and track which future corresponds to which index for i in range(n_elements): future = executor.submit( @@ -536,13 +682,15 @@ def bootstrap_refined(self, instructions: str, grounding_method=grounding_method, output_dir=output_dir, filename=filename, - i=i + i=i, ) future_to_index[future] = i # Process futures as they complete for future in tqdm( - as_completed(future_to_index), total=n_elements, desc="Bootstrapping Refined Mindmaps..." + as_completed(future_to_index), + total=n_elements, + desc="Bootstrapping Refined Mindmaps...", ): i = future_to_index[future] try: @@ -552,7 +700,7 @@ def bootstrap_refined(self, instructions: str, print(f"Error in generating mindmap {i}: {e}") return refined_results - + def generate_dynamic( self, instructions: str, @@ -566,7 +714,7 @@ def generate_dynamic( grounding_method: str = "tool_call", map_type: str = "risk", output_dir: str = "./dynamic_mindmaps", - **llm_kwargs + **llm_kwargs, ) -> List[Dict[str, Any]]: """ Dynamic/iterative mind map generation over time intervals. @@ -579,24 +727,27 @@ def generate_dynamic( instructions, focus, main_theme, map_type=map_type, **llm_kwargs ) prev_mindmap = one_shot["mindmap_text"] - results['base_mindmap'] = one_shot + results["base_mindmap"] = one_shot # Step 2: For each subsequent interval, refine using previous mind map and new search, including starting month - for i, (interval, month_name) in enumerate(zip(month_intervals, month_names), start=0): + for i, (interval, month_name) in enumerate( + zip(month_intervals, month_names), start=0 + ): date_range = self._make_absolute_date_range(interval) - refined = self.generate_refined(focus = focus, - main_theme=main_theme, - initial_mindmap=prev_mindmap, - grounding_method=grounding_method, - output_dir=output_dir, - filename=f"{month_name}.json", - map_type=map_type, - instructions=instructions, - search_scope=search_scope, - sortby=sortby, - date_range=date_range, - chunk_limit=chunk_limit, - **llm_kwargs - ) + refined = self.generate_refined( + focus=focus, + main_theme=main_theme, + initial_mindmap=prev_mindmap, + grounding_method=grounding_method, + output_dir=output_dir, + filename=f"{month_name}.json", + map_type=map_type, + instructions=instructions, + search_scope=search_scope, + sortby=sortby, + date_range=date_range, + chunk_limit=chunk_limit, + **llm_kwargs, + ) results[month_name] = refined prev_mindmap = refined["mindmap_text"] @@ -610,7 +761,7 @@ def _run_and_collate_search( search_scope: Any = None, sortby: Any = None, date_range: Any = None, - chunk_limit: int = 20 + chunk_limit: int = 20, ) -> str: """ Run Bigdata search for each query and collate results for LLM context. @@ -625,10 +776,19 @@ def _run_and_collate_search( # --- Robust date_range parsing --- # If date_range is a list of one tuple, unpack it - if isinstance(date_range, list) and len(date_range) == 1 and isinstance(date_range[0], (tuple, list)) and len(date_range[0]) == 2: + if ( + isinstance(date_range, list) + and len(date_range) == 1 + and isinstance(date_range[0], (tuple, list)) + and len(date_range[0]) == 2 + ): date_range = date_range[0] # If date_range is a tuple/list of two strings, convert to AbsoluteDateRange - if isinstance(date_range, (tuple, list)) and len(date_range) == 2 and all(isinstance(x, str) for x in date_range): + if ( + isinstance(date_range, (tuple, list)) + and len(date_range) == 2 + and all(isinstance(x, str) for x in date_range) + ): date_range = AbsoluteDateRange(start=date_range[0], end=date_range[1]) elif date_range is None: date_range = RollingDateRange.LAST_THIRTY_DAYS @@ -638,41 +798,51 @@ def _run_and_collate_search( entity_objs = [] for entity_name in entities_list: try: - entity = self.bigdata_connection.knowledge_graph.autosuggest(entity_name, limit=1)[0] + entity = self.bigdata_connection.knowledge_graph.autosuggest( + entity_name, limit=1 + )[0] entity_objs.append(entity) except Exception as e: print(f"Warning: Autosuggest failed for '{entity_name}': {e}") continue - print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") - confirmed_entities = [entity for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name] + print( + f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}" + ) + confirmed_entities = [ + entity + for entity, orig_str in zip(entity_objs, entities_list) + if entity.name in orig_str or orig_str in entity.name + ] if confirmed_entities: - entities = Any(confirmed_entities) + entities = BigdataAny(confirmed_entities) else: entities = None else: entities = None if keywords_list: print(f"Searching with keywords: {keywords_list}") - keywords = Any([Keyword(kw) for kw in keywords_list]) + keywords = BigdataAny([Keyword(kw) for kw in keywords_list]) else: keywords = None - + queries = [Similarity(sentence) for sentence in search_list] if entities: - queries = [query&entities for query in queries] + queries = [query & entities for query in queries] if keywords: - queries = [query&keywords for query in queries] - - all_results = run_search(queries=queries, - date_ranges = date_range, - sortby = sortby, - scope = scope, - limit = chunk_limit, - only_results = False, - rerank_threshold = None) + queries = [query & keywords for query in queries] + + all_results = run_search( + queries=queries, + date_ranges=date_range, + sortby=sortby, + scope=scope, + limit=chunk_limit, + only_results=False, + rerank_threshold=None, + ) return self.collate_results(all_results) - + def collate_results(self, results: List[Tuple[str, Any]]) -> str: """ Collate a list of (query, result) tuples into a single string for LLM context. @@ -687,8 +857,8 @@ def collate_results(self, results: List[Tuple[str, Any]]) -> str: for (text_query, date_range), result in results.items(): for item in text_query.items: dictitem = item.to_dict() - if dictitem['type']=='similarity': - sentence = dictitem['value'] + if dictitem["type"] == "similarity": + sentence = dictitem["value"] docstr = f"###Query: {sentence}\n ### Results:\n" for doc in result: headline = getattr(doc, "headline", "No headline") @@ -706,6 +876,7 @@ def _parse_queries(self, queries_json: str) -> List[str]: Parse LLM output (JSON or text) into a list of search queries. """ import json + try: queries = json.loads(queries_json) if isinstance(queries, list): @@ -724,4 +895,4 @@ def _make_absolute_date_range(interval: Tuple[str, str]) -> Any: """ Helper to create an AbsoluteDateRange object from a (start, end) tuple. """ - return AbsoluteDateRange(start=interval[0], end=interval[1]) \ No newline at end of file + return AbsoluteDateRange(start=interval[0], end=interval[1]) diff --git a/src/bigdata_research_tools/mindmap/mindmap_utils.py b/src/bigdata_research_tools/mindmap/mindmap_utils.py index 7265641..4f09b07 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_utils.py +++ b/src/bigdata_research_tools/mindmap/mindmap_utils.py @@ -1,11 +1,15 @@ -import pandas as pd -from io import StringIO -import os import json +import os +from io import StringIO -prompts_dict = {'theme':{'qualifier':'Main Theme', - 'user_prompt_message':'Your given Theme is: {main_theme}', - 'enforce_structure_string':("""IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" +import pandas as pd + +prompts_dict = { + "theme": { + "qualifier": "Main Theme", + "user_prompt_message": "Your given Theme is: {main_theme}", + "enforce_structure_string": ( + """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" "- `node`: an integer representing the unique identifier for the node.\n" "- `label`: a string for the name of the sub-theme.\n" "- `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme.\n" @@ -33,10 +37,13 @@ " ]}\n" " ]\n" "}\n" - """)}, - 'risk':{'qualifier':'Risk Scenario', - 'user_prompt_message':'Your given Risk Scenario is: {main_theme}', - 'enforce_structure_string':( + """ + ), + }, + "risk": { + "qualifier": "Risk Scenario", + "user_prompt_message": "Your given Risk Scenario is: {main_theme}", + "enforce_structure_string": ( """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" " - `node`: an integer representing the unique identifier for the node.\n" " - `label`: a string for the name of the sub-theme.\n" @@ -64,10 +71,12 @@ " ]}\n" " ]\n" "}\n" - """) - } + """ + ), + }, } + def format_mindmap_to_dataframe(mindmap_text): """ Parse a mind map in pipe-delimited table format into a cleaned pandas DataFrame. @@ -83,18 +92,20 @@ def format_mindmap_to_dataframe(mindmap_text): ValueError: If the resulting DataFrame does not contain the required columns. """ try: - df = pd.read_csv(StringIO(mindmap_text.strip()), sep="|", engine="python", skiprows=[1]) - df = df.loc[:, ~df.columns.str.contains('^Unnamed')] - except Exception as e: + df = pd.read_csv( + StringIO(mindmap_text.strip()), sep="|", engine="python", skiprows=[1] + ) + df = df.loc[:, ~df.columns.str.contains("^Unnamed")] + except Exception: try: df = pd.read_csv( StringIO(mindmap_text.strip()), sep="|", engine="python", skiprows=[1], - on_bad_lines='skip' + on_bad_lines="skip", ) - df = df.loc[:, ~df.columns.str.contains('^Unnamed')] + df = df.loc[:, ~df.columns.str.contains("^Unnamed")] except Exception as e2: raise ValueError(f"Failed to parse mindmap text to DataFrame: {e2}") required_columns = {"Main Branches", "Sub-Branches", "Description"} @@ -102,6 +113,7 @@ def format_mindmap_to_dataframe(mindmap_text): raise ValueError(f"Missing required columns in mindmap table: {df.columns}") return df + def save_results_to_file(results, output_dir, filename): """ Save the results to a JSON file. @@ -112,10 +124,11 @@ def save_results_to_file(results, output_dir, filename): with open(output_file, "w") as f: json.dump(results, f, default=str, indent=2) + def load_results_from_file(output_dir, filename): """ Load the results from a JSON file. """ input_file = os.path.join(output_dir, filename) with open(input_file, "r") as f: - return json.load(f) \ No newline at end of file + return json.load(f) diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 6166bc2..2e545f1 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -40,7 +40,12 @@ | list[tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange] ) -INPUT_DATE_RANGE = tuple[datetime, datetime] | RollingDateRange | AbsoluteDateRange | NORMALIZED_DATE_RANGE +INPUT_DATE_RANGE = ( + tuple[datetime, datetime] + | RollingDateRange + | AbsoluteDateRange + | NORMALIZED_DATE_RANGE +) SEARCH_QUERY_RESULTS_TYPE = dict[ tuple[QueryComponent, AbsoluteDateRange | RollingDateRange], list[Document] @@ -250,12 +255,14 @@ def concurrent_search( as_completed(futures), total=len(futures), desc="Querying Bigdata..." ): query, date_range = futures[future] - + try: if isinstance(date_range, AbsoluteDateRange): date_range = f"{date_range.start_dt.isoformat()}_{date_range.end_dt.isoformat()}" elif isinstance(date_range, tuple): - date_range = f"{date_range[0].isoformat()}_{date_range[1].isoformat()}" + date_range = ( + f"{date_range[0].isoformat()}_{date_range[1].isoformat()}" + ) results[(query, date_range)] = future.result() except Exception as e: diff --git a/src/bigdata_research_tools/visuals/mindmap_visuals.py b/src/bigdata_research_tools/visuals/mindmap_visuals.py index 3e1a2f7..3d9ab0a 100644 --- a/src/bigdata_research_tools/visuals/mindmap_visuals.py +++ b/src/bigdata_research_tools/visuals/mindmap_visuals.py @@ -17,7 +17,7 @@ 2. **Text Fitting Algorithm**: - Uses binary search (30-40 iterations) to find optimal font size for each text element - Text wrapping respects word boundaries only (no mid-word breaks) - - Separate fitting strategies for main branches (strict, 88% safety margin) and + - Separate fitting strategies for main branches (strict, 88% safety margin) and sub-branches (prioritizes larger fonts, 90% safety margin, minimal wrapping) - Measures actual rendered text dimensions on the target figure for accuracy @@ -36,23 +36,27 @@ The algorithm guarantees no text overflow, no ellipsis, and proper spacing while maintaining visual hierarchy and readability. """ + import matplotlib -matplotlib.use('Agg') # Use non-interactive backend -import matplotlib.pyplot as plt -import matplotlib.patches as patches -from matplotlib import rcParams -import pandas as pd -import numpy as np + +matplotlib.use("Agg") # Use non-interactive backend import math import os -from typing import Tuple, Dict, List, Optional +from typing import Dict, Tuple + +import matplotlib.patches as patches +import matplotlib.pyplot as plt +import pandas as pd +from matplotlib import rcParams + from bigdata_research_tools.mindmap.mindmap import MindMap ##Note: In case the mindmap overflows from the chart area and edges, you can try to adjust the padding system by shrinking the 'available chart area'. For example, if padding does not work, you can try to have the algorithm think that the available area is 95% of the original available area. In this way, the algorithm will have less space to work with and will try to fit the text within the available area. + class MindmapPlotter: """Main class for plotting mindmaps with graphviz-inspired layout.""" - + def __init__( self, mindmap: pd.DataFrame | MindMap, @@ -60,68 +64,74 @@ def __init__( title: str = "Mind Map", color_scheme: str = "gold", output_dir: str = "./outputs", - aspect_ratio: float = 8/9 + aspect_ratio: float = 8 / 9, ): """Initialize the mindmap plotter.""" if isinstance(mindmap, pd.DataFrame): self.df = mindmap.copy() elif isinstance(mindmap, MindMap): self.df = mindmap.to_dataframe() - + # Handle flexible column names: either (Parent, Label) or (Main Branches, Sub-Branches) - if 'Main Branches' in self.df.columns and 'Sub-Branches' in self.df.columns: + if "Main Branches" in self.df.columns and "Sub-Branches" in self.df.columns: # Already in correct format pass - elif 'Parent' in self.df.columns and 'Label' in self.df.columns: + elif "Parent" in self.df.columns and "Label" in self.df.columns: # Rename to standard format - self.df = self.df.rename(columns={'Parent': 'Main Branches', 'Label': 'Sub-Branches'}) + self.df = self.df.rename( + columns={"Parent": "Main Branches", "Label": "Sub-Branches"} + ) else: # Check what columns we have - has_main = 'Main Branches' in self.df.columns - has_sub = 'Sub-Branches' in self.df.columns - has_parent = 'Parent' in self.df.columns - has_label = 'Label' in self.df.columns - + has_main = "Main Branches" in self.df.columns + has_sub = "Sub-Branches" in self.df.columns + has_parent = "Parent" in self.df.columns + has_label = "Label" in self.df.columns + raise ValueError( f"DataFrame must have either (Parent, Label) or (Main Branches, Sub-Branches) columns. " f"Found columns: {list(self.df.columns)}. " f"Has Main Branches: {has_main}, Has Sub-Branches: {has_sub}, " f"Has Parent: {has_parent}, Has Label: {has_label}" ) - + # Assert required columns exist - assert 'Main Branches' in self.df.columns, "Missing 'Main Branches' column after processing" - assert 'Sub-Branches' in self.df.columns, "Missing 'Sub-Branches' column after processing" - - self.df['Main Branches'] = self.df['Main Branches'].astype(str).str.strip() - self.df['Sub-Branches'] = self.df['Sub-Branches'].astype(str).str.strip() - + assert "Main Branches" in self.df.columns, ( + "Missing 'Main Branches' column after processing" + ) + assert "Sub-Branches" in self.df.columns, ( + "Missing 'Sub-Branches' column after processing" + ) + + self.df["Main Branches"] = self.df["Main Branches"].astype(str).str.strip() + self.df["Sub-Branches"] = self.df["Sub-Branches"].astype(str).str.strip() + self.main_theme = main_theme self.title = title self.color_scheme = color_scheme self.output_dir = output_dir self.aspect_ratio = aspect_ratio - + os.makedirs(output_dir, exist_ok=True) - - plt.rcParams['svg.fonttype'] = 'none' - rcParams['font.family'] = 'DejaVu Sans' - + + plt.rcParams["svg.fonttype"] = "none" + rcParams["font.family"] = "DejaVu Sans" + self.colors = self._get_color_scheme(color_scheme) - - self.main_branches = self.df['Main Branches'].unique() + + self.main_branches = self.df["Main Branches"].unique() self.n_main = len(self.main_branches) - + self.sub_branches = { - main: self.df[self.df['Main Branches'] == main]['Sub-Branches'].tolist() + main: self.df[self.df["Main Branches"] == main]["Sub-Branches"].tolist() for main in self.main_branches } self.n_sub_total = sum(len(subs) for subs in self.sub_branches.values()) - + self.fig_width = 10.0 self.fig_height = self.fig_width / aspect_ratio self.title_height = 0 # No title - removed to maximize space - + # Add padding to prevent rectangles from going over chart edges self.edge_padding = 0.25 # Padding on sides self.top_padding = 0.1 # Minimal top padding @@ -131,20 +141,33 @@ def __init__( self.sub_linewidth = 2.0 # Sub-branch linewidth # Linewidth extends by half on each side, convert points to inches (72 points per inch) # Be very conservative: use 3x linewidth as padding to ensure borders don't get cut - self.linewidth_padding = (self.max_linewidth / 72.0) * 3.0 # Very conservative padding - self.sub_linewidth_padding = (self.sub_linewidth / 72.0) * 3.0 # Sub-branch linewidth padding + self.linewidth_padding = ( + self.max_linewidth / 72.0 + ) * 3.0 # Very conservative padding + self.sub_linewidth_padding = ( + self.sub_linewidth / 72.0 + ) * 3.0 # Sub-branch linewidth padding # Use minimal top padding, extra bottom padding # Reduce available_height to account for linewidth extension at bottom - self.available_height = self.fig_height - self.title_height - self.top_padding - (self.bottom_padding + self.sub_linewidth_padding) - self.available_width = self.fig_width - 2 * (self.edge_padding + self.linewidth_padding) + self.available_height = ( + self.fig_height + - self.title_height + - self.top_padding + - (self.bottom_padding + self.sub_linewidth_padding) + ) + self.available_width = self.fig_width - 2 * ( + self.edge_padding + self.linewidth_padding + ) # Calculate absolute bottom boundary - no rectangle should exceed this # Use sub_linewidth_padding since sub-branches are at the bottom - self.absolute_bottom = self.fig_height - self.bottom_padding - self.sub_linewidth_padding - + self.absolute_bottom = ( + self.fig_height - self.bottom_padding - self.sub_linewidth_padding + ) + # Increased padding for text inside boxes self.pad_x = 0.7 # Increased from 0.5 self.pad_y = 0.7 # Increased from 0.5 - + def _get_color_scheme(self, scheme: str) -> Dict[str, str]: """Get color scheme dictionary.""" schemes = { @@ -152,67 +175,67 @@ def _get_color_scheme(self, scheme: str) -> Dict[str, str]: "edge": "#eab720", "link": "#eab720", "background": "none", # Transparent - "text": "black" + "text": "black", }, "light_blue": { "edge": "#206EB5", "link": "#206EB5", "background": "none", # Transparent - "text": "black" + "text": "black", }, "dark_blue": { "edge": "#2C318C", "link": "#2C318C", "background": "none", # Transparent - "text": "black" - } + "text": "black", + }, } return schemes.get(scheme, schemes["gold"]) - + def _measure_text(self, ax, text: str, fontsize: float) -> Tuple[float, float]: """Measure text dimensions accurately.""" if not text: return self.pad_x, self.pad_y - - t = ax.text(0, 0, text, fontsize=fontsize, ha='left', va='bottom') - if not hasattr(ax.figure.canvas, 'renderer'): + + t = ax.text(0, 0, text, fontsize=fontsize, ha="left", va="bottom") + if not hasattr(ax.figure.canvas, "renderer"): ax.figure.canvas.draw() renderer = ax.figure.canvas.get_renderer() bbox = t.get_window_extent(renderer=renderer) t.remove() - + width = bbox.width / ax.figure.dpi + self.pad_x height = bbox.height / ax.figure.dpi + self.pad_y - + return width, height - + def _wrap_text(self, text: str, max_chars: int) -> str: """Wrap text at word boundaries.""" if not text: return text - + lines = [] - for para in text.split('\n'): + for para in text.split("\n"): if not para: - lines.append('') + lines.append("") continue - + words = para.split() if not words: - lines.append('') + lines.append("") continue - + line = words[0] for word in words[1:]: - if len(line + ' ' + word) <= max_chars: - line += ' ' + word + if len(line + " " + word) <= max_chars: + line += " " + word else: lines.append(line) line = word lines.append(line) - - return '\n'.join(lines) - + + return "\n".join(lines) + def _fit_text_main( self, ax, @@ -220,81 +243,91 @@ def _fit_text_main( max_width: float, max_height: float, initial_font: float, - min_font: float = 9 + min_font: float = 9, ) -> Tuple[float, str, float, float]: """ Fit text for main branches - STRICT no overflow. Returns: fontsize, wrapped_text, actual_width, actual_height """ if not text: - return min_font, '', self.pad_x, self.pad_y - + return min_font, "", self.pad_x, self.pad_y + available_w = max(0.1, max_width - self.pad_x) - available_h = max(0.1, max_height - self.pad_y) - + _available_h = max(0.1, max_height - self.pad_y) + # Binary search for optimal font size low_font = min_font high_font = initial_font * 1.3 best = None - + for _ in range(40): test_font = (low_font + high_font) / 2.0 - + # Less aggressive wrapping - prioritize font size chars_per_inch = test_font / 12.0 * 7 # More chars per inch wrap_chars = max(20, int(available_w * chars_per_inch)) # Minimum 20 chars - + wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, test_font) - + if w <= max_width * 0.88 and h <= max_height * 0.88: best = (test_font, wrapped, w, h) low_font = test_font + 0.2 else: high_font = test_font - 0.2 - + if high_font < low_font: break - + if best: return best - + # Fallback: ensure it fits fontsize = min_font chars_per_inch = fontsize / 12.0 * 7 wrap_chars = max(20, int(available_w * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Keep reducing until it fits - more aggressive max_attempts = 60 attempt = 0 - while (w > max_width * 0.88 or h > max_height * 0.88) and fontsize > min_font * 0.7 and attempt < max_attempts: + while ( + (w > max_width * 0.88 or h > max_height * 0.88) + and fontsize > min_font * 0.7 + and attempt < max_attempts + ): attempt += 1 - scale = min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) * 0.97 + scale = ( + min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) + * 0.97 + ) fontsize = max(min_font * 0.7, fontsize * scale) chars_per_inch = fontsize / 12.0 * 7 wrap_chars = max(20, int(available_w * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Final verification - ensure text actually fits w, h = self._measure_text(ax, wrapped, fontsize) if w > max_width * 0.88 or h > max_height * 0.88: # Force one more reduction - scale = min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) * 0.98 + scale = ( + min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) + * 0.98 + ) fontsize = max(min_font * 0.7, fontsize * scale) chars_per_inch = fontsize / 12.0 * 7 wrap_chars = max(20, int((max_width - self.pad_x) * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Final safety check - clip dimensions w = min(w, max_width * 0.88) h = min(h, max_height * 0.88) - + return fontsize, wrapped, w, h - + def _fit_text_sub( self, ax, @@ -302,117 +335,131 @@ def _fit_text_sub( max_width: float, max_height: float, initial_font: float, - min_font: float = 8 + min_font: float = 8, ) -> Tuple[float, str, float, float]: """ Fit text for sub-branches - prioritize large font, minimal wrapping. Returns: fontsize, wrapped_text, actual_width, actual_height """ if not text: - return min_font, '', self.pad_x, self.pad_y - + return min_font, "", self.pad_x, self.pad_y + available_w = max(0.1, max_width - self.pad_x) - available_h = max(0.1, max_height - self.pad_y) - + _available_h = max(0.1, max_height - self.pad_y) + # Binary search prioritizing larger fonts low_font = min_font high_font = initial_font * 1.5 best = None - + for _ in range(40): test_font = (low_font + high_font) / 2.0 - + # Much less aggressive wrapping - wide lines preferred chars_per_inch = test_font / 12.0 * 8 # Even more chars per inch - wrap_chars = max(25, int(available_w * chars_per_inch)) # Minimum 25 chars, prefer wide lines - + wrap_chars = max( + 25, int(available_w * chars_per_inch) + ) # Minimum 25 chars, prefer wide lines + wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, test_font) - + if w <= max_width * 0.90 and h <= max_height * 0.90: best = (test_font, wrapped, w, h) low_font = test_font + 0.3 # Try even larger else: high_font = test_font - 0.3 - + if high_font < low_font: break - + if best: return best - + # Fallback: use minimum font with wide wrapping fontsize = min_font chars_per_inch = fontsize / 12.0 * 8 wrap_chars = max(25, int(available_w * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Only reduce if absolutely necessary max_attempts = 30 attempt = 0 - while (w > max_width * 0.90 or h > max_height * 0.90) and fontsize > min_font * 0.85 and attempt < max_attempts: + while ( + (w > max_width * 0.90 or h > max_height * 0.90) + and fontsize > min_font * 0.85 + and attempt < max_attempts + ): attempt += 1 - scale = min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) * 0.98 + scale = ( + min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) + * 0.98 + ) fontsize = max(min_font * 0.85, fontsize * scale) chars_per_inch = fontsize / 12.0 * 8 wrap_chars = max(25, int(available_w * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Final verification - ensure text actually fits w, h = self._measure_text(ax, wrapped, fontsize) if w > max_width * 0.90 or h > max_height * 0.90: # Force one more reduction - scale = min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) * 0.99 + scale = ( + min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) + * 0.99 + ) fontsize = max(min_font * 0.85, fontsize * scale) chars_per_inch = fontsize / 12.0 * 8 wrap_chars = max(25, int((max_width - self.pad_x) * chars_per_inch)) wrapped = self._wrap_text(text, wrap_chars) w, h = self._measure_text(ax, wrapped, fontsize) - + # Final safety check - clip dimensions w = min(w, max_width * 0.90) h = min(h, max_height * 0.90) - + return fontsize, wrapped, w, h - + def _calculate_layout(self) -> Dict: """Calculate complete layout with uniform dimensions and scaling.""" fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) ax_temp = fig_temp.add_subplot(111) - ax_temp.axis('off') - + ax_temp.axis("off") + # Base font calculation total_elements = self.n_main + self.n_sub_total - area_per_element = (self.available_height * self.available_width) / max(1, total_elements) - + area_per_element = (self.available_height * self.available_width) / max( + 1, total_elements + ) + ref_area = 1.0 if area_per_element > ref_area: area_factor = math.pow(area_per_element / ref_area, 0.6) else: area_factor = math.pow(area_per_element / ref_area, 0.7) - + area_factor = max(0.4, min(3.5, area_factor)) base_font = max(8, min(26, 12 * area_factor)) - + # Initial dimensions (will be scaled if needed) main_spacing = self.available_height * 0.06 available_h = self.available_height - (self.n_main - 1) * main_spacing uniform_main_h = max(0.7, min(1.6, available_h / max(1, self.n_main))) - + max_main_len = max([len(m) for m in self.main_branches], default=20) uniform_main_w = max(1.3, min(2.5, max_main_len * 0.10)) - + # Theme width matches main branch width theme_w = uniform_main_w theme_h = uniform_main_h - + # Sub-branch dimensions max_subs = max([len(subs) for subs in self.sub_branches.values()], default=1) spacing_extra = main_spacing * 0.4 available_sub_h = uniform_main_h + 2 * spacing_extra - + if max_subs > 0: sub_spacing = 0.12 available_for_subs = available_sub_h - (max_subs - 1) * sub_spacing @@ -420,21 +467,21 @@ def _calculate_layout(self) -> Dict: else: uniform_sub_h = 0.8 sub_spacing = 0.12 - + all_subs = [s for subs in self.sub_branches.values() for s in subs] max_sub_len = max([len(s) for s in all_subs], default=30) if all_subs else 30 max_w = self.available_width * 0.28 uniform_sub_w = max(2.2, min(max_w, max_sub_len * 0.12)) - + # Calculate column positions (accounting for edge padding and linewidth) margin = self.edge_padding + self.linewidth_padding col_spacing = self.available_width * 0.07 - + x_theme = margin x_main = x_theme + theme_w + col_spacing x_sub = x_main + uniform_main_w + col_spacing total_width_needed = x_sub + uniform_sub_w + margin - + # Horizontal scaling if needed h_scale = 1.0 if total_width_needed > self.available_width * 0.96: @@ -448,7 +495,7 @@ def _calculate_layout(self) -> Dict: x_theme = margin x_main = x_theme + theme_w + col_spacing x_sub = x_main + uniform_main_w + col_spacing - + # Vertical scaling check total_h = uniform_main_h * self.n_main + main_spacing * (self.n_main - 1) v_scale = 1.0 @@ -459,27 +506,31 @@ def _calculate_layout(self) -> Dict: uniform_sub_h *= v_scale main_spacing *= v_scale sub_spacing *= v_scale - + # Now fit text with final scaled dimensions # Fit all main branches to find uniform font main_fonts = [] for main in self.main_branches: - font, _, _, _ = self._fit_text_main(ax_temp, main, uniform_main_w, uniform_main_h, base_font * 0.9, 10) + font, _, _, _ = self._fit_text_main( + ax_temp, main, uniform_main_w, uniform_main_h, base_font * 0.9, 10 + ) main_fonts.append(font) - + uniform_main_font = min(main_fonts) if main_fonts else base_font * 0.9 - + # Fit all main branches with uniform dimensions - STRICT no overflow main_data = {} for main in self.main_branches: - font, wrapped, w, h = self._fit_text_main(ax_temp, main, uniform_main_w, uniform_main_h, uniform_main_font, 10) + font, wrapped, w, h = self._fit_text_main( + ax_temp, main, uniform_main_w, uniform_main_h, uniform_main_font, 10 + ) main_data[main] = { - 'fontsize': font, - 'text': wrapped, - 'width': uniform_main_w, - 'height': uniform_main_h + "fontsize": font, + "text": wrapped, + "width": uniform_main_w, + "height": uniform_main_h, } - + # Theme layout - use same width as main theme_font, theme_wrapped, theme_w_actual, theme_h_actual = self._fit_text_main( ax_temp, self.main_theme, theme_w, theme_h, base_font * 1.0, 11 @@ -487,326 +538,422 @@ def _calculate_layout(self) -> Dict: # Use target dimensions, not measured theme_w = uniform_main_w theme_h = uniform_main_h - + # Fit all sub-branches - prioritize large font, minimal wrapping sub_fonts = [] for sub in all_subs: - font, _, _, _ = self._fit_text_sub(ax_temp, sub, uniform_sub_w * 0.94, uniform_sub_h * 0.94, base_font * 1.0, 9) + font, _, _, _ = self._fit_text_sub( + ax_temp, + sub, + uniform_sub_w * 0.94, + uniform_sub_h * 0.94, + base_font * 1.0, + 9, + ) sub_fonts.append(font) - + uniform_sub_font = min(sub_fonts) if sub_fonts else base_font * 1.0 - + # Fit all sub-branches with uniform dimensions sub_data = {} for main in self.main_branches: subs = self.sub_branches[main] main_sub_data = [] - + for sub in subs: font, wrapped, w, h = self._fit_text_sub( - ax_temp, sub, uniform_sub_w * 0.94, uniform_sub_h * 0.94, uniform_sub_font, 9 + ax_temp, + sub, + uniform_sub_w * 0.94, + uniform_sub_h * 0.94, + uniform_sub_font, + 9, ) - - main_sub_data.append({ - 'fontsize': font, - 'text': wrapped, - 'width': uniform_sub_w, - 'height': uniform_sub_h - }) - + + main_sub_data.append( + { + "fontsize": font, + "text": wrapped, + "width": uniform_sub_w, + "height": uniform_sub_h, + } + ) + sub_data[main] = main_sub_data - + plt.close(fig_temp) - + return { - 'theme': {'fontsize': theme_font, 'text': theme_wrapped, 'width': theme_w, 'height': theme_h}, - 'main_data': main_data, - 'sub_data': sub_data, - 'uniform_main_w': uniform_main_w, - 'uniform_main_h': uniform_main_h, - 'uniform_sub_w': uniform_sub_w, - 'uniform_sub_h': uniform_sub_h, - 'main_spacing': main_spacing, - 'sub_spacing': sub_spacing, - 'margin': margin, - 'col_spacing': col_spacing, - 'x_theme': x_theme, - 'x_main': x_main, - 'x_sub': x_sub + "theme": { + "fontsize": theme_font, + "text": theme_wrapped, + "width": theme_w, + "height": theme_h, + }, + "main_data": main_data, + "sub_data": sub_data, + "uniform_main_w": uniform_main_w, + "uniform_main_h": uniform_main_h, + "uniform_sub_w": uniform_sub_w, + "uniform_sub_h": uniform_sub_h, + "main_spacing": main_spacing, + "sub_spacing": sub_spacing, + "margin": margin, + "col_spacing": col_spacing, + "x_theme": x_theme, + "x_main": x_main, + "x_sub": x_sub, } - - def _check_overflow(self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) -> Dict: + + def _check_overflow( + self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) -> Dict: """Check for text and rectangle overflow, return adjustments needed.""" issues = { - 'theme_overflow': False, - 'main_overflows': {}, - 'sub_overflows': {}, - 'bottom_cutoff': False + "theme_overflow": False, + "main_overflows": {}, + "sub_overflows": {}, + "bottom_cutoff": False, } - + # Check theme text overflow theme_text = ax.text( - x_theme + layout['theme']['width'] / 2, - theme_y + layout['theme']['height'] / 2, - layout['theme']['text'], - ha='center', va='center', fontsize=layout['theme']['fontsize'], - fontweight='bold' + x_theme + layout["theme"]["width"] / 2, + theme_y + layout["theme"]["height"] / 2, + layout["theme"]["text"], + ha="center", + va="center", + fontsize=layout["theme"]["fontsize"], + fontweight="bold", ) renderer = ax.figure.canvas.get_renderer() bbox = theme_text.get_window_extent(renderer=renderer) theme_text.remove() text_w = bbox.width / ax.figure.dpi text_h = bbox.height / ax.figure.dpi - if text_w > layout['theme']['width'] * 0.88 or text_h > layout['theme']['height'] * 0.88: - issues['theme_overflow'] = True - + if ( + text_w > layout["theme"]["width"] * 0.88 + or text_h > layout["theme"]["height"] * 0.88 + ): + issues["theme_overflow"] = True + # Check main branch text overflow for main in self.main_branches: main_y = main_positions[main] - main_info = layout['main_data'][main] + main_info = layout["main_data"][main] main_text = ax.text( - x_main + layout['uniform_main_w'] / 2, - main_y + layout['uniform_main_h'] / 2, - main_info['text'], - ha='center', va='center', fontsize=main_info['fontsize'], - fontweight='bold' + x_main + layout["uniform_main_w"] / 2, + main_y + layout["uniform_main_h"] / 2, + main_info["text"], + ha="center", + va="center", + fontsize=main_info["fontsize"], + fontweight="bold", ) bbox = main_text.get_window_extent(renderer=renderer) main_text.remove() text_w = bbox.width / ax.figure.dpi text_h = bbox.height / ax.figure.dpi - if text_w > layout['uniform_main_w'] * 0.88 or text_h > layout['uniform_main_h'] * 0.88: - issues['main_overflows'][main] = True - + if ( + text_w > layout["uniform_main_w"] * 0.88 + or text_h > layout["uniform_main_h"] * 0.88 + ): + issues["main_overflows"][main] = True + # Check sub-branch overflow and bottom cutoff max_bottom_y = 0 for main in self.main_branches: main_y = main_positions[main] - subs = layout['sub_data'].get(main, []) + subs = layout["sub_data"].get(main, []) if subs: - spacing_extra = layout['main_spacing'] * 0.4 + spacing_extra = layout["main_spacing"] * 0.4 min_sub_y = main_y - spacing_extra - max_sub_y = main_y + layout['uniform_main_h'] + spacing_extra - - sub_spacing = layout['sub_spacing'] - total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing - main_center = main_y + layout['uniform_main_h'] / 2 + max_sub_y = main_y + layout["uniform_main_h"] + spacing_extra + + sub_spacing = layout["sub_spacing"] + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + (len(subs) - 1) * sub_spacing + ) + main_center = main_y + layout["uniform_main_h"] / 2 sub_y_start = main_center - total_sub_h / 2 - + if sub_y_start < min_sub_y: sub_y_start = min_sub_y if sub_y_start + total_sub_h > max_sub_y: sub_y_start = max_sub_y - total_sub_h # Calculate maximum allowed bottom position (use absolute bottom) min_top_y = self.top_padding + self.linewidth_padding - + if sub_y_start < min_top_y: sub_y_start = min_top_y # Ensure total height doesn't exceed absolute bottom if sub_y_start + total_sub_h > self.absolute_bottom: sub_y_start = max(min_top_y, self.absolute_bottom - total_sub_h) - + sub_y = sub_y_start for i, sub_info in enumerate(subs): - if sub_y + layout['uniform_sub_h'] > max_bottom_y: - issues['bottom_cutoff'] = True + if sub_y + layout["uniform_sub_h"] > max_bottom_y: + issues["bottom_cutoff"] = True break - + # Check text overflow sub_text = ax.text( - x_sub + layout['uniform_sub_w'] / 2, - sub_y + layout['uniform_sub_h'] / 2, - sub_info['text'], - ha='center', va='center', fontsize=sub_info['fontsize'], - fontweight='bold' + x_sub + layout["uniform_sub_w"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + sub_info["text"], + ha="center", + va="center", + fontsize=sub_info["fontsize"], + fontweight="bold", ) bbox = sub_text.get_window_extent(renderer=renderer) sub_text.remove() text_w = bbox.width / ax.figure.dpi text_h = bbox.height / ax.figure.dpi - if text_w > layout['uniform_sub_w'] * 0.90 or text_h > layout['uniform_sub_h'] * 0.90: - issues['sub_overflows'][(main, i)] = True - - bottom_y = sub_y + layout['uniform_sub_h'] + if ( + text_w > layout["uniform_sub_w"] * 0.90 + or text_h > layout["uniform_sub_h"] * 0.90 + ): + issues["sub_overflows"][(main, i)] = True + + bottom_y = sub_y + layout["uniform_sub_h"] if bottom_y > max_bottom_y: max_bottom_y = bottom_y - - sub_y += layout['uniform_sub_h'] + sub_spacing - if sub_y + layout['uniform_sub_h'] > max_sub_y: + + sub_y += layout["uniform_sub_h"] + sub_spacing + if sub_y + layout["uniform_sub_h"] > max_sub_y: break - + # Check if bottom sub-branch is too close to edge (accounting for padding and linewidth) if max_bottom_y > self.absolute_bottom - 0.1: - issues['bottom_cutoff'] = True - + issues["bottom_cutoff"] = True + return issues - + def plot(self) -> Tuple[plt.Figure, plt.Axes]: """Create the mindmap plot with iterative refinement.""" layout = self._calculate_layout() - + # Iterative refinement loop max_iterations = 5 for iteration in range(max_iterations): fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) - ax.axis('off') - + ax.axis("off") + # Use pre-calculated positions from layout - x_theme = layout['x_theme'] - x_main = layout['x_main'] - x_sub = layout['x_sub'] - + x_theme = layout["x_theme"] + x_main = layout["x_main"] + x_sub = layout["x_sub"] + # Main branch positions - total_h = layout['uniform_main_h'] * self.n_main + layout['main_spacing'] * (self.n_main - 1) + total_h = layout["uniform_main_h"] * self.n_main + layout[ + "main_spacing" + ] * (self.n_main - 1) y_start = (self.available_height - total_h) / 2 - + main_positions = {} y = y_start for main in self.main_branches: main_positions[main] = y - y += layout['uniform_main_h'] + layout['main_spacing'] - + y += layout["uniform_main_h"] + layout["main_spacing"] + # Theme position (centered with main branches) - theme_y = y_start + (total_h - layout['theme']['height']) / 2 - + theme_y = y_start + (total_h - layout["theme"]["height"]) / 2 + # Draw elements to check overflow - self._draw_elements(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) - + self._draw_elements( + ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) + # Ensure figure is drawn for accurate measurement ax.figure.canvas.draw() - + # Check for overflow - issues = self._check_overflow(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) - + issues = self._check_overflow( + ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) + # If no issues, we're done - if not issues['theme_overflow'] and not issues['main_overflows'] and not issues['sub_overflows'] and not issues['bottom_cutoff']: + if ( + not issues["theme_overflow"] + and not issues["main_overflows"] + and not issues["sub_overflows"] + and not issues["bottom_cutoff"] + ): plt.close(fig) break - + # Adjust layout based on issues - if issues['theme_overflow'] or issues['main_overflows'] or issues['bottom_cutoff']: + if ( + issues["theme_overflow"] + or issues["main_overflows"] + or issues["bottom_cutoff"] + ): # Reduce font sizes for theme and main branches layout = self._adjust_layout_for_overflow(layout, issues, iteration) plt.close(fig) continue - + plt.close(fig) break - + # Final render fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) - ax.axis('off') - + ax.axis("off") + # Use pre-calculated positions from layout - x_theme = layout['x_theme'] - x_main = layout['x_main'] - x_sub = layout['x_sub'] - + x_theme = layout["x_theme"] + x_main = layout["x_main"] + x_sub = layout["x_sub"] + # Main branch positions (accounting for top padding and linewidth) - total_h = layout['uniform_main_h'] * self.n_main + layout['main_spacing'] * (self.n_main - 1) - y_start = self.top_padding + self.linewidth_padding + (self.available_height - total_h) / 2 - + total_h = layout["uniform_main_h"] * self.n_main + layout["main_spacing"] * ( + self.n_main - 1 + ) + y_start = ( + self.top_padding + + self.linewidth_padding + + (self.available_height - total_h) / 2 + ) + main_positions = {} y = y_start for main in self.main_branches: main_positions[main] = y - y += layout['uniform_main_h'] + layout['main_spacing'] - + y += layout["uniform_main_h"] + layout["main_spacing"] + # Theme position (centered with main branches) - theme_y = y_start + (total_h - layout['theme']['height']) / 2 - + theme_y = y_start + (total_h - layout["theme"]["height"]) / 2 + # Draw all elements self._draw_elements(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) - + # Set strict limits to prevent anything from being drawn outside bounds # Account for linewidth extension - clip everything strictly ax.set_xlim(0, self.fig_width) ax.set_ylim(0, self.fig_height) # Clip all patches and text to axes limits ax.set_clip_on(True) - + return fig, ax - - def _draw_elements(self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y): + + def _draw_elements( + self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ): """Draw all mindmap elements.""" # Draw theme theme_rect = patches.Rectangle( - (x_theme, theme_y), layout['theme']['width'], layout['theme']['height'], - linewidth=2.5, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2 + (x_theme, theme_y), + layout["theme"]["width"], + layout["theme"]["height"], + linewidth=2.5, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, ) ax.add_patch(theme_rect) ax.text( - x_theme + layout['theme']['width'] / 2, - theme_y + layout['theme']['height'] / 2, - layout['theme']['text'], - ha='center', va='center', fontsize=layout['theme']['fontsize'], - color=self.colors['text'], fontweight='bold', zorder=3 + x_theme + layout["theme"]["width"] / 2, + theme_y + layout["theme"]["height"] / 2, + layout["theme"]["text"], + ha="center", + va="center", + fontsize=layout["theme"]["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, ) - + # Draw main branches and sub-branches for main in self.main_branches: main_y = main_positions[main] - main_info = layout['main_data'][main] - + main_info = layout["main_data"][main] + # Main branch rectangle main_rect = patches.Rectangle( - (x_main, main_y), layout['uniform_main_w'], layout['uniform_main_h'], - linewidth=2.5, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2 + (x_main, main_y), + layout["uniform_main_w"], + layout["uniform_main_h"], + linewidth=2.5, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, ) ax.add_patch(main_rect) ax.text( - x_main + layout['uniform_main_w'] / 2, - main_y + layout['uniform_main_h'] / 2, - main_info['text'], - ha='center', va='center', fontsize=main_info['fontsize'], - color=self.colors['text'], fontweight='bold', zorder=3 + x_main + layout["uniform_main_w"] / 2, + main_y + layout["uniform_main_h"] / 2, + main_info["text"], + ha="center", + va="center", + fontsize=main_info["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, ) - + # Connection theme to main ax.plot( - [x_theme + layout['theme']['width'], x_main], - [theme_y + layout['theme']['height'] / 2, main_y + layout['uniform_main_h'] / 2], - color=self.colors['link'], linewidth=3, alpha=0.6, zorder=1, solid_capstyle='round' + [x_theme + layout["theme"]["width"], x_main], + [ + theme_y + layout["theme"]["height"] / 2, + main_y + layout["uniform_main_h"] / 2, + ], + color=self.colors["link"], + linewidth=3, + alpha=0.6, + zorder=1, + solid_capstyle="round", ) - + # Sub-branches - subs = layout['sub_data'].get(main, []) + subs = layout["sub_data"].get(main, []) if subs: - spacing_extra = layout['main_spacing'] * 0.4 + spacing_extra = layout["main_spacing"] * 0.4 min_sub_y = main_y - spacing_extra - max_sub_y = main_y + layout['uniform_main_h'] + spacing_extra + max_sub_y = main_y + layout["uniform_main_h"] + spacing_extra # Ensure max_sub_y doesn't exceed absolute bottom max_sub_y = min(max_sub_y, self.absolute_bottom) available_sub_h = max_sub_y - min_sub_y - - sub_spacing = layout['sub_spacing'] - total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing - + + sub_spacing = layout["sub_spacing"] + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + (len(subs) - 1) * sub_spacing + ) + # Adjust spacing if needed if total_sub_h > available_sub_h: - max_sp = (available_sub_h - layout['uniform_sub_h'] * len(subs)) / max(1, len(subs) - 1) + max_sp = ( + available_sub_h - layout["uniform_sub_h"] * len(subs) + ) / max(1, len(subs) - 1) sub_spacing = max(0.08, min(sub_spacing, max_sp)) - total_sub_h = layout['uniform_sub_h'] * len(subs) + (len(subs) - 1) * sub_spacing - + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + + (len(subs) - 1) * sub_spacing + ) + # Center on main branch - calculate ideal center position - main_center = main_y + layout['uniform_main_h'] / 2 + main_center = main_y + layout["uniform_main_h"] / 2 ideal_sub_y_start = main_center - total_sub_h / 2 - + # Determine available space boundaries (use the most restrictive) absolute_min = self.top_padding + self.linewidth_padding absolute_max = self.absolute_bottom relative_min = min_sub_y relative_max = max_sub_y - + # Use the most restrictive boundaries min_top_y = max(absolute_min, relative_min) max_bottom_y = min(absolute_max, relative_max) - + # Start with ideal centered position sub_y_start = ideal_sub_y_start - + # If the entire block fits within available space, use centered position - if ideal_sub_y_start >= min_top_y and ideal_sub_y_start + total_sub_h <= max_bottom_y: + if ( + ideal_sub_y_start >= min_top_y + and ideal_sub_y_start + total_sub_h <= max_bottom_y + ): sub_y_start = ideal_sub_y_start else: # Block doesn't fit centered - adjust to fit while maintaining centering as much as possible @@ -819,126 +966,154 @@ def _draw_elements(self, ax, layout, x_theme, x_main, x_sub, main_positions, the # Ensure we don't go below minimum if sub_y_start < min_top_y: sub_y_start = min_top_y - + # Final safety check: ensure we don't exceed absolute bottom if sub_y_start + total_sub_h > absolute_max: sub_y_start = max(min_top_y, absolute_max - total_sub_h) - + # Draw sub-branches sub_y = sub_y_start # Use pre-calculated sub_linewidth_padding # Use absolute bottom boundary - no rectangle should exceed this for sub_info in subs: # STRICT check: rectangle bottom must not exceed absolute bottom - rect_bottom = sub_y + layout['uniform_sub_h'] - + rect_bottom = sub_y + layout["uniform_sub_h"] + # Don't draw if rectangle itself would exceed absolute bottom if rect_bottom > self.absolute_bottom: break # Don't draw this or any subsequent sub-branches - if sub_y + layout['uniform_sub_h'] > max_sub_y: + if sub_y + layout["uniform_sub_h"] > max_sub_y: break - + # Extra safety check - leave margin for linewidth extension # Linewidth extends by half outward, so ensure rect_bottom + half_linewidth <= absolute_bottom half_linewidth_extension = (self.sub_linewidth / 2.0) / 72.0 if rect_bottom + half_linewidth_extension > self.absolute_bottom: break - + # Sub-branch rectangle - clip to axes to prevent overflow sub_rect = patches.Rectangle( - (x_sub, sub_y), layout['uniform_sub_w'], layout['uniform_sub_h'], - linewidth=self.sub_linewidth, edgecolor=self.colors['edge'], facecolor=self.colors['background'], zorder=2, - clip_on=True + (x_sub, sub_y), + layout["uniform_sub_w"], + layout["uniform_sub_h"], + linewidth=self.sub_linewidth, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, + clip_on=True, ) ax.add_patch(sub_rect) - + # Sub-branch text ax.text( - x_sub + layout['uniform_sub_w'] / 2, - sub_y + layout['uniform_sub_h'] / 2, - sub_info['text'], - ha='center', va='center', fontsize=sub_info['fontsize'], - color=self.colors['text'], fontweight='bold', zorder=3 + x_sub + layout["uniform_sub_w"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + sub_info["text"], + ha="center", + va="center", + fontsize=sub_info["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, ) - + # Connection main to sub ax.plot( - [x_main + layout['uniform_main_w'], x_sub], - [main_y + layout['uniform_main_h'] / 2, sub_y + layout['uniform_sub_h'] / 2], - color=self.colors['link'], linewidth=2.5, alpha=0.5, zorder=1, solid_capstyle='round' + [x_main + layout["uniform_main_w"], x_sub], + [ + main_y + layout["uniform_main_h"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + ], + color=self.colors["link"], + linewidth=2.5, + alpha=0.5, + zorder=1, + solid_capstyle="round", ) - - sub_y += layout['uniform_sub_h'] + sub_spacing - - if sub_y + layout['uniform_sub_h'] > max_sub_y: + + sub_y += layout["uniform_sub_h"] + sub_spacing + + if sub_y + layout["uniform_sub_h"] > max_sub_y: break - + # Title removed to maximize space for mindmap - + def _adjust_layout_for_overflow(self, layout, issues, iteration): """Adjust layout to fix overflow issues.""" # Create temp figure for re-fitting fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) ax_temp = fig_temp.add_subplot(111) - ax_temp.axis('off') - + ax_temp.axis("off") + # Reduce font sizes more aggressively reduction_factor = 0.92 - (iteration * 0.02) # More aggressive each iteration - + # Adjust theme - if issues['theme_overflow']: - current_font = layout['theme']['fontsize'] + if issues["theme_overflow"]: + current_font = layout["theme"]["fontsize"] new_font = max(9, current_font * reduction_factor) font, wrapped, _, _ = self._fit_text_main( - ax_temp, self.main_theme, layout['theme']['width'], layout['theme']['height'], - new_font, 9 + ax_temp, + self.main_theme, + layout["theme"]["width"], + layout["theme"]["height"], + new_font, + 9, ) - layout['theme']['fontsize'] = font - layout['theme']['text'] = wrapped - + layout["theme"]["fontsize"] = font + layout["theme"]["text"] = wrapped + # Adjust main branches - if issues['main_overflows']: - for main in issues['main_overflows']: - current_font = layout['main_data'][main]['fontsize'] + if issues["main_overflows"]: + for main in issues["main_overflows"]: + current_font = layout["main_data"][main]["fontsize"] new_font = max(8, current_font * reduction_factor) font, wrapped, _, _ = self._fit_text_main( - ax_temp, main, layout['uniform_main_w'], layout['uniform_main_h'], - new_font, 8 + ax_temp, + main, + layout["uniform_main_w"], + layout["uniform_main_h"], + new_font, + 8, ) - layout['main_data'][main]['fontsize'] = font - layout['main_data'][main]['text'] = wrapped - + layout["main_data"][main]["fontsize"] = font + layout["main_data"][main]["text"] = wrapped + # Adjust for bottom cutoff - reduce vertical spacing or sub-branch height - if issues['bottom_cutoff']: + if issues["bottom_cutoff"]: # Reduce sub-branch height slightly - layout['uniform_sub_h'] *= 0.95 - layout['sub_spacing'] *= 0.95 + layout["uniform_sub_h"] *= 0.95 + layout["sub_spacing"] *= 0.95 # Re-fit all sub-branches for main in self.main_branches: - subs = layout['sub_data'].get(main, []) + subs = layout["sub_data"].get(main, []) for i, sub_info in enumerate(subs): sub_text = self.sub_branches[main][i] font, wrapped, _, _ = self._fit_text_sub( - ax_temp, sub_text, layout['uniform_sub_w'] * 0.94, - layout['uniform_sub_h'] * 0.94, layout['sub_data'][main][i]['fontsize'], 8 + ax_temp, + sub_text, + layout["uniform_sub_w"] * 0.94, + layout["uniform_sub_h"] * 0.94, + layout["sub_data"][main][i]["fontsize"], + 8, ) - layout['sub_data'][main][i]['fontsize'] = font - layout['sub_data'][main][i]['text'] = wrapped - + layout["sub_data"][main][i]["fontsize"] = font + layout["sub_data"][main][i]["text"] = wrapped + plt.close(fig_temp) return layout - + def save(self, fig: plt.Figure): """Save the figure as PNG and SVG.""" - filename = self.title.replace(' ', '_') + filename = self.title.replace(" ", "_") png_path = os.path.join(self.output_dir, f"{filename}.png") svg_path = os.path.join(self.output_dir, f"{filename}.svg") - + # Don't use bbox_inches='tight' to ensure we stay within figure bounds # The axes limits are already set correctly in plot() fig.savefig(png_path, transparent=True, dpi=300) fig.savefig(svg_path, transparent=True) - + print(f"Saved: {png_path}") print(f"Saved: {svg_path}") @@ -949,11 +1124,12 @@ def plot_mindmap( title: str = "Mind Map", color_scheme: str = "gold", output_dir: str = "./outputs", - aspect_ratio: float = 8/9 + aspect_ratio: float = 8 / 9, ) -> Tuple[plt.Figure, plt.Axes]: - """Plot a mindmap from a DataFrame.""" - plotter = MindmapPlotter(mindmap, main_theme, title, color_scheme, output_dir, aspect_ratio) + plotter = MindmapPlotter( + mindmap, main_theme, title, color_scheme, output_dir, aspect_ratio + ) fig, ax = plotter.plot() plotter.save(fig) return fig, ax diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index 9eef2ea..5ba8882 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -9,6 +9,10 @@ from bigdata_research_tools.excel import check_excel_dependencies, save_to_excel from bigdata_research_tools.labeler.risk_labeler import RiskLabeler, map_risk_category from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap import ( + MindMap, + generate_risk_tree, +) from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -17,10 +21,6 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.mindmap.mindmap import ( - MindMap, - generate_risk_tree, -) from bigdata_research_tools.workflows.base import Workflow from bigdata_research_tools.workflows.utils import get_scored_df diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index cbf62f8..f33b187 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -9,6 +9,7 @@ from bigdata_research_tools.excel import check_excel_dependencies, save_to_excel from bigdata_research_tools.labeler.screener_labeler import ScreenerLabeler from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap import generate_theme_tree from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -17,7 +18,6 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.mindmap.mindmap import generate_theme_tree from bigdata_research_tools.workflows.base import Workflow from bigdata_research_tools.workflows.utils import get_scored_df From 77354250da5351acbe3f2192e07460877ea55d2c Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Tue, 18 Nov 2025 16:11:45 +0000 Subject: [PATCH 06/14] robust grounding search --- examples/grounded_mindmaps.py | 4 +-- .../mindmap/mindmap_generator.py | 25 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index b3750bc..ce30dde 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -128,8 +128,8 @@ def test_dynamic_mindmap( ], month_names=["October_2025", "November_2025", "December_2025"], ) - logger.info("Results: %s", mindmap["base_mindmap"]) - logger.info("Results: %s", mindmap["October_2025"]) + logger.info("Results: %s", mindmap["base_mindmap"]['mindmap_json']) + logger.info("Results: %s", mindmap["October_2025"]['mindmap_json']) logger.info("") diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 7c18f2f..65a504f 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -14,6 +14,7 @@ from bigdata_client.query import ( Keyword, Similarity, + Entity ) from tqdm import tqdm @@ -798,27 +799,25 @@ def _run_and_collate_search( entity_objs = [] for entity_name in entities_list: try: - entity = self.bigdata_connection.knowledge_graph.autosuggest( - entity_name, limit=1 - )[0] - entity_objs.append(entity) + suggestions = self.bigdata_connection.knowledge_graph.autosuggest(entity_name, limit=1) + if suggestions: # Check if list is not empty + entity = suggestions[0] + entity_objs.append(entity) + else: + print(f"Warning: No autosuggest results for '{entity_name}'") except Exception as e: print(f"Warning: Autosuggest failed for '{entity_name}': {e}") continue - print( - f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}" - ) - confirmed_entities = [ - entity - for entity, orig_str in zip(entity_objs, entities_list) - if entity.name in orig_str or orig_str in entity.name - ] + + confirmed_entities = [entity.id for entity, orig_str in zip(entity_objs, entities_list) if entity.name.lower() in orig_str.lower() or orig_str.lower() in entity.name.lower()] if confirmed_entities: - entities = BigdataAny(confirmed_entities) + + entities = Any([Entity(entity) for entity in confirmed_entities]) else: entities = None else: entities = None + print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") if keywords_list: print(f"Searching with keywords: {keywords_list}") keywords = BigdataAny([Keyword(kw) for kw in keywords_list]) From 96c22173735c77e453ce2630f978acf3b5866478 Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Tue, 18 Nov 2025 18:31:39 +0000 Subject: [PATCH 07/14] improved typing and robust argument passing --- examples/grounded_mindmaps.py | 11 +- .../mindmap/mindmap_generator.py | 419 +++++++----------- 2 files changed, 166 insertions(+), 264 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index ce30dde..ac66db1 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -60,7 +60,6 @@ def test_refined_mindmap( focus=focus, main_theme=main_theme, initial_mindmap=base_mindmap, - grounding_method="tool_call", output_dir="./refined_mindmaps", filename="refined_mindmap.json", map_type=map_type, @@ -92,7 +91,7 @@ def test_refined_mindmap2( focus=focus, main_theme=main_theme, initial_mindmap=base_mindmap, - grounding_method="tool_call", + date_range = ("2025-10-01", "2025-10-31"), output_dir="./refined_mindmaps", filename="refined_mindmap.json", map_type=map_type, @@ -122,11 +121,11 @@ def test_dynamic_mindmap( focus=focus, main_theme=main_theme, month_intervals=[ - ["2025-10-01", "2025-10-31"], - ["2025-11-01", "2025-11-30"], - ["2025-12-01", "2025-12-31"], + ("2025-09-01", "2025-09-30"), + ("2025-10-01", "2025-10-31"), + ("2025-11-01", "2025-11-30"), ], - month_names=["October_2025", "November_2025", "December_2025"], + month_names=["September_2025", "October_2025", "November_2025", ], ) logger.info("Results: %s", mindmap["base_mindmap"]['mindmap_json']) logger.info("Results: %s", mindmap["October_2025"]['mindmap_json']) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 65a504f..41b3e2c 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -4,13 +4,14 @@ import re from concurrent.futures import ThreadPoolExecutor, as_completed from logging import Logger, getLogger -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Optional from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange from bigdata_client.models.search import DocumentType, SortBy from bigdata_client.query import ( Any as BigdataAny, ) +from bigdata_client.models.search import DocumentType, SortBy from bigdata_client.query import ( Keyword, Similarity, @@ -232,12 +233,17 @@ def compose_tool_call_message( focus: str, map_type: str, instructions: Optional[str], + date_range: Optional[tuple[str,str]], initial_mindmap: Optional[str], ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." + if initial_mindmap: - tool_prompt += f"Starting from the following mind map:\n{initial_mindmap}" + tool_prompt += f"\nStarting from the following mind map:\n{initial_mindmap}" + if date_range is not None: + tool_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" tool_prompt += f"\nReturn a list of searches you would like to perform to enhance it.\n{enforce_structure}" @@ -296,16 +302,22 @@ def compose_final_message( focus: str, map_type: str, instructions: Optional[str], + date_range: Optional[tuple[str,str]], tool_calls, tool_call_id, context, ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + final_prompt = f"{instructions} {focus}. \nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}" + + if date_range is not None: + tool_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + final_message = [ { "role": "system", - "content": f"{instructions} {focus}. IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant. \n{enforce_structure}", + "content": final_prompt, }, { "role": "user", @@ -325,6 +337,7 @@ def compose_refinement_message( focus: str, map_type: str, instructions: Optional[str], + date_range: Optional[tuple[str,str]], initial_mindmap: str, context: str, tool_calls, @@ -332,12 +345,11 @@ def compose_refinement_message( ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] - refine_prompt = ( - f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus} " - "Based on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text." - "IMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant." - f"{enforce_structure}." - ) + refine_prompt = f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus}.\nBased on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text.\nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}." + + if date_range is not None: + refine_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + refinement_messages = [ {"role": "system", "content": refine_prompt}, {"role": "user", "content": initial_mindmap}, @@ -349,115 +361,73 @@ def compose_refinement_message( def generate_one_shot( self, - focus: str, main_theme: str, - instructions: Optional[str] = None, + focus: str, allow_grounding: bool = False, - grounding_method: str = "tool_call", - date_range: Optional[Tuple[str, str]] = None, + instructions: Optional[str] = None, + date_range: Optional[tuple[str, str]] = None, map_type: str = "risk", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Generate a mind map in one LLM call, optionally allowing the LLM to request grounding. If allow_grounding is True, use the specified grounding_method ("tool_call" or "chat"). Optionally log intermediate steps to disk. """ - messages = self.compose_base_message(main_theme, focus, map_type, instructions) + messages = self.compose_base_message(main_theme=main_theme, focus=focus, map_type=map_type, instructions=instructions) llm_kwargs = self.llm_model_config_base.get_llm_kwargs( remove_max_tokens=True, remove_timeout=True ) if allow_grounding: - if grounding_method == "tool_call": - messages.append( - { - "role": "user", - "content": "You can use news search to find relevant information about the topic. " - "Use the Bigdata API to search for news articles related to the topic and use them to inform your response. You will need to specify a list of sentences, a list of entities, and a list of keywords.", - } - ) - tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( - self.send_tool_call(messages, self.llm_base, llm_kwargs) + messages = self.compose_tool_call_message( + main_theme=main_theme, focus=focus, map_type=map_type, instructions=instructions, date_range=date_range, + ) + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_base, llm_kwargs) + ) + + if search_list and isinstance(search_list, list): + context = self._run_and_collate_search( + search_list, entities_list, keywords_list, date_range=date_range ) - if search_list and isinstance(search_list, list): - context = self._run_and_collate_search( - search_list, entities_list, keywords_list, date_range=date_range - ) + final_messages = self.compose_final_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + tool_calls=tool_calls, + tool_call_id=tool_call_id, + context=context, + ) - final_messages = self.compose_final_message( - main_theme, - focus, - map_type, - instructions, - tool_calls, - tool_call_id, - context, - ) + mindmap_text = self.llm_base.get_response(final_messages) - mindmap_text = self.llm_base.get_response(final_messages) - - theme_tree = self._parse_llm_to_themetree(mindmap_text) - df = self._themetree_to_dataframe(theme_tree) - return { - "mindmap_text": mindmap_text, - "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), ##where does this come from? - "grounded": True, - "search_queries": search_list, - "search_context": context, - } - else: - # decide if this fallback should be simplified - mindmap_text = search_list if isinstance(search_list, str) else "" - theme_tree = self._parse_llm_to_themetree( - mindmap_text - ) ## check if correct - df = format_mindmap_to_dataframe(mindmap_text) - return { - "mindmap_text": mindmap_text, - "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), - "grounded": False, - } + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + return { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), ##where does this come from? + "grounded": True, + "search_queries": search_list, + "search_context": context, + } else: # decide if this fallback should be simplified - messages[0]["content"] += ( - " You may request news search to ground your mind map. " - "If you want to search, return a list of queries." - ) - response = self.llm_base.get_response(messages) - - queries = self._parse_queries(response) - - if queries: - context = self._run_and_collate_search(queries, [], []) - - followup_messages = [ - {"role": "system", "content": f"{instructions} {focus}"}, - { - "role": "user", - "content": prompts_dict[map_type][ - "user_prompt_message" - ].format(main_theme=main_theme), - }, - { - "role": "assistant", - "content": "News search results:\n" + context, - }, - ] - mindmap_text = self.llm_base.get_response(followup_messages) - - df = format_mindmap_to_dataframe(mindmap_text) - return { - "mindmap_text": mindmap_text, - "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), - "grounded": True, - "search_queries": queries, - "search_context": context, - } + mindmap_text = search_list if isinstance(search_list, str) else "" + theme_tree = self._parse_llm_to_themetree( + mindmap_text + ) ## check if correct + df = format_mindmap_to_dataframe(mindmap_text) + return { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "grounded": False, + } # Default: just generate mind map mindmap_text = self.llm_base.get_response(messages) @@ -473,98 +443,61 @@ def generate_one_shot( def generate_refined( self, - focus: str, main_theme: str, + focus: str, initial_mindmap: str, - grounding_method: str = "tool_call", output_dir: str = "./refined_mindmaps", filename: str = "refined_mindmap.json", map_type: str = "risk", instructions: Optional[str] = None, - search_scope: Optional[Any] = None, - sortby: Optional[Any] = None, - date_range: Optional[Any] = None, + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, chunk_limit: Optional[int] = 20, **llm_kwargs, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. Optionally log intermediate steps to disk. """ + messages = self.compose_tool_call_message( - main_theme, focus, map_type, instructions, initial_mindmap + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=initial_mindmap ) llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs( remove_max_tokens=True, remove_timeout=True ) - if grounding_method == "tool_call": - tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( - self.send_tool_call(messages, self.llm_reasoning, llm_kwargs=llm_kwargs) - ) - - if search_list and isinstance(search_list, list): - context = self._run_and_collate_search( - search_list, - entities_list, - keywords_list, - search_scope, - sortby, - date_range, - chunk_limit, - ) - - refinement_messages = self.compose_refinement_message( - main_theme, - focus, - map_type, - instructions, - initial_mindmap, - context, - tool_calls, - tool_call_id, - ) - mindmap_text = self.llm_reasoning.get_response(refinement_messages) - theme_tree = self._parse_llm_to_themetree(mindmap_text) - df = self._themetree_to_dataframe(theme_tree) - result_dict = { - "mindmap_text": mindmap_text, - "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), - "search_queries": search_list, - "search_context": context, - } - save_results_to_file(result_dict, output_dir, filename) - return result_dict - else: - mindmap_text = search_list if isinstance(search_list, str) else "" - df = format_mindmap_to_dataframe(mindmap_text) - result_dict = { - "mindmap_text": mindmap_text, - "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), - "search_queries": [], - "search_context": "", - } - save_results_to_file(result_dict, output_dir, filename) - return result_dict - else: - queries_json = self.llm_reasoning.get_response(messages) + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_reasoning, llm_kwargs=llm_kwargs) + ) - search_queries = self._parse_queries(queries_json) + if search_list and isinstance(search_list, list): context = self._run_and_collate_search( - search_queries, [], [], search_scope, sortby, date_range, chunk_limit + search_list, + entities_list, + keywords_list, + search_scope, + sortby, + date_range, + chunk_limit, ) refinement_messages = self.compose_refinement_message( - main_theme, - focus, - map_type, - instructions, - initial_mindmap, - context, - tool_calls, - tool_call_id, + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=initial_mindmap, + tool_calls=tool_calls, + tool_call_id=tool_call_id, + context=context ) mindmap_text = self.llm_reasoning.get_response(refinement_messages) @@ -574,26 +507,36 @@ def generate_refined( "mindmap_text": mindmap_text, "mindmap_df": df, "mindmap_json": theme_tree.to_json(), - "search_queries": search_queries, + "search_queries": search_list, "search_context": context, } save_results_to_file(result_dict, output_dir, filename) return result_dict + else: + mindmap_text = search_list if isinstance(search_list, str) else "" + df = format_mindmap_to_dataframe(mindmap_text) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "search_queries": [], + "search_context": "", + } + save_results_to_file(result_dict, output_dir, filename) + return result_dict + def generate_or_load_refined( self, - instructions: str, - focus: str, main_theme: str, + focus: str, map_type: str, initial_mindmap: str, - llm_model: str = "o3-mini", - reasoning_effort: str = "high", - search_scope: Any = None, - sortby: Any = None, - date_range: Any = None, - chunk_limit: int = 20, - grounding_method: str = "tool_call", + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: Optional[int] = 20, output_dir: str = "./bootstrapped_mindmaps", filename: str = "refined_mindmap", i: int = 0, @@ -609,9 +552,10 @@ def generate_or_load_refined( main_theme=main_theme, map_type=map_type, initial_mindmap=initial_mindmap, - reasoning_effort=reasoning_effort, - grounding_method=grounding_method, date_range=date_range, + search_scope=search_scope, + sortby=sortby, + chunk_limit=chunk_limit, output_dir=output_dir, filename=f"{filename}_{i}.json", ) @@ -624,8 +568,6 @@ def generate_or_load_refined( main_theme=main_theme, map_type=map_type, initial_mindmap=initial_mindmap, - reasoning_effort=reasoning_effort, - grounding_method=grounding_method, date_range=date_range, output_dir=output_dir, filename=f"{filename}_{i}.json", @@ -635,16 +577,15 @@ def generate_or_load_refined( def bootstrap_refined( self, - instructions: str, - focus: str, main_theme: str, + focus: str, map_type: str, initial_mindmap: str, - search_scope: Any = None, - sortby: Any = None, - date_range: Any = None, + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, chunk_limit: int = 20, - grounding_method: str = "tool_call", output_dir: str = "./bootstrapped_mindmaps", filename: str = "refined_mindmap", n_elements: int = 50, @@ -680,7 +621,6 @@ def bootstrap_refined( sortby=sortby, date_range=date_range, chunk_limit=chunk_limit, - grounding_method=grounding_method, output_dir=output_dir, filename=filename, i=i, @@ -704,19 +644,18 @@ def bootstrap_refined( def generate_dynamic( self, - instructions: str, - focus: str, main_theme: str, - month_intervals: List[Tuple[str, str]], - month_names: List[str], - search_scope: Any = None, - sortby: Any = None, - chunk_limit: int = 20, - grounding_method: str = "tool_call", + focus: str, + month_intervals: list[tuple[str, str]], + month_names: list[str], + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + chunk_limit: Optional[int] = 20, map_type: str = "risk", output_dir: str = "./dynamic_mindmaps", **llm_kwargs, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Dynamic/iterative mind map generation over time intervals. Returns a list of dicts, one per interval. @@ -725,23 +664,28 @@ def generate_dynamic( results = {} # Step 1: Generate initial mind map for t0 one_shot = self.generate_one_shot( - instructions, focus, main_theme, map_type=map_type, **llm_kwargs + main_theme=main_theme, + focus=focus, + allow_grounding=False, + instructions=instructions, + map_type=map_type, + **llm_kwargs ) - prev_mindmap = one_shot["mindmap_text"] + prev_mindmap = one_shot["mindmap_json"] + print(prev_mindmap) results["base_mindmap"] = one_shot # Step 2: For each subsequent interval, refine using previous mind map and new search, including starting month - for i, (interval, month_name) in enumerate( + for i, (date_range, month_name) in enumerate( zip(month_intervals, month_names), start=0 ): - date_range = self._make_absolute_date_range(interval) + refined = self.generate_refined( - focus=focus, main_theme=main_theme, + focus=focus, initial_mindmap=prev_mindmap, - grounding_method=grounding_method, + map_type=map_type, output_dir=output_dir, filename=f"{month_name}.json", - map_type=map_type, instructions=instructions, search_scope=search_scope, sortby=sortby, @@ -751,18 +695,18 @@ def generate_dynamic( ) results[month_name] = refined - prev_mindmap = refined["mindmap_text"] + prev_mindmap = refined["mindmap_json"] return results def _run_and_collate_search( self, - search_list: List[str], - entities_list: List[str], - keywords_list: List[str], - search_scope: Any = None, - sortby: Any = None, - date_range: Any = None, - chunk_limit: int = 20, + search_list: list[str], + entities_list: Optional[list[str]], + keywords_list: Optional[list[str]], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: Optional[int] = 20, ) -> str: """ Run Bigdata search for each query and collate results for LLM context. @@ -775,24 +719,10 @@ def _run_and_collate_search( scope = search_scope if search_scope is not None else DocumentType.NEWS sortby = sortby if sortby is not None else SortBy.RELEVANCE - # --- Robust date_range parsing --- - # If date_range is a list of one tuple, unpack it - if ( - isinstance(date_range, list) - and len(date_range) == 1 - and isinstance(date_range[0], (tuple, list)) - and len(date_range[0]) == 2 - ): - date_range = date_range[0] - # If date_range is a tuple/list of two strings, convert to AbsoluteDateRange - if ( - isinstance(date_range, (tuple, list)) - and len(date_range) == 2 - and all(isinstance(x, str) for x in date_range) - ): - date_range = AbsoluteDateRange(start=date_range[0], end=date_range[1]) - elif date_range is None: + if date_range is None: date_range = RollingDateRange.LAST_THIRTY_DAYS + else: + date_range = AbsoluteDateRange(start=date_range[0], end=date_range[1]) if entities_list: print(f"Entities List: {entities_list}") @@ -812,7 +742,7 @@ def _run_and_collate_search( confirmed_entities = [entity.id for entity, orig_str in zip(entity_objs, entities_list) if entity.name.lower() in orig_str.lower() or orig_str.lower() in entity.name.lower()] if confirmed_entities: - entities = Any([Entity(entity) for entity in confirmed_entities]) + entities = BigdataAny([Entity(entity) for entity in confirmed_entities]) else: entities = None else: @@ -842,7 +772,7 @@ def _run_and_collate_search( return self.collate_results(all_results) - def collate_results(self, results: List[Tuple[str, Any]]) -> str: + def collate_results(self, results: dict[tuple[str, str], list]) -> str: """ Collate a list of (query, result) tuples into a single string for LLM context. @@ -868,30 +798,3 @@ def collate_results(self, results: List[Tuple[str, Any]]) -> str: docstr += f"{chunk.text}\n" doctexts.append(docstr) return "\n".join(doctexts) - - @staticmethod - def _parse_queries(self, queries_json: str) -> List[str]: - """ - Parse LLM output (JSON or text) into a list of search queries. - """ - import json - - try: - queries = json.loads(queries_json) - if isinstance(queries, list): - return queries - elif isinstance(queries, dict) and "search_list" in queries: - return queries["search_list"] - elif isinstance(queries, dict) and "queries" in queries: - return queries["queries"] - except Exception: - # Fallback: split by lines - return [q.strip() for q in queries_json.splitlines() if q.strip()] - return [] - - @staticmethod - def _make_absolute_date_range(interval: Tuple[str, str]) -> Any: - """ - Helper to create an AbsoluteDateRange object from a (start, end) tuple. - """ - return AbsoluteDateRange(start=interval[0], end=interval[1]) From c08938a6b0d7950cac5d88b2fda85f8f077b61d1 Mon Sep 17 00:00:00 2001 From: jaldana Date: Wed, 19 Nov 2025 09:47:51 +0100 Subject: [PATCH 08/14] Improve typing --- examples/grounded_mindmaps.py | 10 ++++---- src/bigdata_research_tools/llm/openai.py | 2 +- .../mindmap/mindmap_generator.py | 24 ++++++++----------- src/bigdata_research_tools/search/search.py | 2 +- .../visuals/mindmap_visuals.py | 3 ++- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index ac66db1..f9adf78 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -22,7 +22,7 @@ def test_one_shot_mindmap( map_type, instructions, llm_base_config: str = "openai::gpt-4o-mini", -) -> MindMap: +): """Test one-shot mind map generation with base LLM.""" logger.info("=" * 60) logger.info("TEST 1: One-Shot Mind Map Generation with Base LLM") @@ -48,7 +48,7 @@ def test_refined_mindmap( instructions, base_mindmap: str, llm_base_config: str = "openai::o3-mini", -) -> MindMap: +): """Test refined mindmap generation with reasoning LLM sent in the base config.""" logger.info("=" * 60) logger.info("TEST 2: Refined MindMap Generation with Reasoning LLM in Base Config") @@ -74,9 +74,9 @@ def test_refined_mindmap2( map_type, instructions, base_mindmap: str, - llm_base_config: str | None = None, + llm_base_config: str, llm_reasoning_config: str = "openai::o3-mini", -) -> MindMap: +): """Test refined mindmap generation with reasoning LLM sent in the reasoning config.""" logger.info("=" * 60) logger.info( @@ -107,7 +107,7 @@ def test_dynamic_mindmap( instructions, llm_base_config: str = "openai::gpt-4o-mini", llm_reasoning_config: str = "openai::o3-mini", -) -> MindMap: +): """Test dynamic mindmap generation with two LLMs.""" logger.info("=" * 60) logger.info("TEST 4: Dynamic MindMap Generation with Two LLMs") diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 7ec6fe6..61a54b8 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -169,7 +169,7 @@ def get_tools_response( chat_history: list[dict[str, str]], tools: list[dict[str, str]], **kwargs, - ) -> dict[str, list[dict] | str]: + ) -> dict[str, list[dict] | str | list[str]]: """ Get the response from an LLM model from OpenAI with tools. Args: diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 41b3e2c..947aab8 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -29,14 +29,10 @@ prompts_dict, save_results_to_file, ) +from bigdata_client.models.advanced_search_query import QueryComponent +from bigdata_research_tools.search.search import SEARCH_QUERY_RESULTS_TYPE -# from bigdata_research_tools.search.query_builder import ( -# EntitiesToSearch, -# build_batched_query, -# create_date_ranges, -# ) -# cannot use query builder because it is to error-prone to build EntitiesToSearch based on the LLM output -from bigdata_research_tools.search.search import run_search +from bigdata_research_tools.search.search import run_search, INPUT_DATE_RANGE logger: Logger = getLogger(__name__) @@ -261,7 +257,7 @@ def compose_tool_call_message( def send_tool_call( self, messages: list, llm_client: LLMEngine, llm_kwargs: dict - ) -> list: + ) -> tuple[str | None, dict | None, list | None, list | None, list | None]: llm_kwargs.update( { "tool_choice": { @@ -341,7 +337,7 @@ def compose_refinement_message( initial_mindmap: str, context: str, tool_calls, - tool_call_id, + tool_call_id: str | None, ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] @@ -754,7 +750,7 @@ def _run_and_collate_search( else: keywords = None - queries = [Similarity(sentence) for sentence in search_list] + queries: list[QueryComponent] = [Similarity(sentence) for sentence in search_list] if entities: queries = [query & entities for query in queries] if keywords: @@ -784,10 +780,10 @@ def collate_results(self, results: dict[tuple[str, str], list]) -> str: """ doctexts = [] for (text_query, date_range), result in results.items(): - for item in text_query.items: - dictitem = item.to_dict() - if dictitem["type"] == "similarity": - sentence = dictitem["value"] + + dictitem = text_query.to_dict() + if dictitem["type"] == "similarity": + sentence = dictitem["value"] docstr = f"###Query: {sentence}\n ### Results:\n" for doc in result: headline = getattr(doc, "headline", "No headline") diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 2e545f1..9aefaf1 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -48,7 +48,7 @@ ) SEARCH_QUERY_RESULTS_TYPE = dict[ - tuple[QueryComponent, AbsoluteDateRange | RollingDateRange], list[Document] + tuple[QueryComponent, str], list[Document] ] REQUESTS_PER_MINUTE_LIMIT = 300 diff --git a/src/bigdata_research_tools/visuals/mindmap_visuals.py b/src/bigdata_research_tools/visuals/mindmap_visuals.py index 3d9ab0a..c09b468 100644 --- a/src/bigdata_research_tools/visuals/mindmap_visuals.py +++ b/src/bigdata_research_tools/visuals/mindmap_visuals.py @@ -38,6 +38,7 @@ """ import matplotlib +from typing import Any matplotlib.use("Agg") # Use non-interactive backend import math @@ -609,7 +610,7 @@ def _check_overflow( self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y ) -> Dict: """Check for text and rectangle overflow, return adjustments needed.""" - issues = { + issues: dict[str, Any] = { "theme_overflow": False, "main_overflows": {}, "sub_overflows": {}, From ed42c464f58de3f985d1028cb62ba118c3119edd Mon Sep 17 00:00:00 2001 From: jaldana Date: Wed, 19 Nov 2025 10:36:54 +0100 Subject: [PATCH 09/14] Remove all type errors --- src/bigdata_research_tools/llm/azure.py | 4 +-- src/bigdata_research_tools/llm/base.py | 8 ++--- src/bigdata_research_tools/llm/bedrock.py | 4 +-- src/bigdata_research_tools/llm/openai.py | 6 ++-- .../mindmap/mindmap_generator.py | 34 +++++++++---------- src/bigdata_research_tools/search/search.py | 22 +++++------- 6 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/bigdata_research_tools/llm/azure.py b/src/bigdata_research_tools/llm/azure.py index 7929568..37ac3b9 100644 --- a/src/bigdata_research_tools/llm/azure.py +++ b/src/bigdata_research_tools/llm/azure.py @@ -97,7 +97,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -234,7 +234,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 413f420..4979064 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -112,7 +112,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -202,7 +202,7 @@ async def get_stream_response( async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -244,7 +244,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -330,7 +330,7 @@ def get_stream_response( def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ diff --git a/src/bigdata_research_tools/llm/bedrock.py b/src/bigdata_research_tools/llm/bedrock.py index e50456e..2107722 100644 --- a/src/bigdata_research_tools/llm/bedrock.py +++ b/src/bigdata_research_tools/llm/bedrock.py @@ -113,7 +113,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -274,7 +274,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 61a54b8..54b7116 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -58,7 +58,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -167,9 +167,9 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], **kwargs, - ) -> dict[str, list[dict] | str | list[str]]: + ) -> dict: """ Get the response from an LLM model from OpenAI with tools. Args: diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 947aab8..40ac243 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -257,7 +257,7 @@ def compose_tool_call_message( def send_tool_call( self, messages: list, llm_client: LLMEngine, llm_kwargs: dict - ) -> tuple[str | None, dict | None, list | None, list | None, list | None]: + ) -> tuple: llm_kwargs.update( { "tool_choice": { @@ -275,9 +275,9 @@ def send_tool_call( if response_dict["tool_calls"] is not None: tool_call_id = response_dict["id"][0] arguments = response_dict["arguments"][0] - search_list = arguments.get("search_list", []) - entities_list = arguments.get("entities_list", []) - keywords_list = arguments.get("keywords_list", []) + search_list = arguments.get("search_list", []) # ty: ignore[possibly-missing-attribute] + entities_list = arguments.get("entities_list", []) # ty: ignore[possibly-missing-attribute] + keywords_list = arguments.get("keywords_list", []) # ty: ignore[possibly-missing-attribute] return ( tool_call_id, response_dict["tool_calls"], @@ -307,9 +307,6 @@ def compose_final_message( final_prompt = f"{instructions} {focus}. \nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}" - if date_range is not None: - tool_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" - final_message = [ { "role": "system", @@ -378,6 +375,7 @@ def generate_one_shot( if allow_grounding: messages = self.compose_tool_call_message( main_theme=main_theme, focus=focus, map_type=map_type, instructions=instructions, date_range=date_range, + initial_mindmap=None ) tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( self.send_tool_call(messages, self.llm_base, llm_kwargs) @@ -449,7 +447,7 @@ def generate_refined( search_scope: Optional[DocumentType] = None, sortby: Optional[SortBy] = None, date_range: Optional[tuple[str, str]] = None, - chunk_limit: Optional[int] = 20, + chunk_limit: int = 20, **llm_kwargs, ) -> dict[str, Any]: """ @@ -514,7 +512,7 @@ def generate_refined( result_dict = { "mindmap_text": mindmap_text, "mindmap_df": df, - "mindmap_json": theme_tree.to_json(), + "mindmap_json": "", "search_queries": [], "search_context": "", } @@ -532,7 +530,7 @@ def generate_or_load_refined( search_scope: Optional[DocumentType] = None, sortby: Optional[SortBy] = None, date_range: Optional[tuple[str, str]] = None, - chunk_limit: Optional[int] = 20, + chunk_limit: int = 20, output_dir: str = "./bootstrapped_mindmaps", filename: str = "refined_mindmap", i: int = 0, @@ -647,11 +645,11 @@ def generate_dynamic( instructions: Optional[str], search_scope: Optional[DocumentType] = None, sortby: Optional[SortBy] = None, - chunk_limit: Optional[int] = 20, + chunk_limit: int = 20, map_type: str = "risk", output_dir: str = "./dynamic_mindmaps", **llm_kwargs, - ) -> list[dict[str, Any]]: + ) -> dict[str, dict[str, Any]]: """ Dynamic/iterative mind map generation over time intervals. Returns a list of dicts, one per interval. @@ -702,7 +700,7 @@ def _run_and_collate_search( search_scope: Optional[DocumentType] = None, sortby: Optional[SortBy] = None, date_range: Optional[tuple[str, str]] = None, - chunk_limit: Optional[int] = 20, + chunk_limit: int = 20, ) -> str: """ Run Bigdata search for each query and collate results for LLM context. @@ -716,9 +714,9 @@ def _run_and_collate_search( sortby = sortby if sortby is not None else SortBy.RELEVANCE if date_range is None: - date_range = RollingDateRange.LAST_THIRTY_DAYS + date_range_filter = RollingDateRange.LAST_THIRTY_DAYS else: - date_range = AbsoluteDateRange(start=date_range[0], end=date_range[1]) + date_range_filter = AbsoluteDateRange(start=date_range[0], end=date_range[1]) if entities_list: print(f"Entities List: {entities_list}") @@ -741,9 +739,9 @@ def _run_and_collate_search( entities = BigdataAny([Entity(entity) for entity in confirmed_entities]) else: entities = None + print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") else: entities = None - print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") if keywords_list: print(f"Searching with keywords: {keywords_list}") keywords = BigdataAny([Keyword(kw) for kw in keywords_list]) @@ -758,7 +756,7 @@ def _run_and_collate_search( all_results = run_search( queries=queries, - date_ranges=date_range, + date_ranges=date_range_filter, sortby=sortby, scope=scope, limit=chunk_limit, @@ -768,7 +766,7 @@ def _run_and_collate_search( return self.collate_results(all_results) - def collate_results(self, results: dict[tuple[str, str], list]) -> str: + def collate_results(self, results: dict) -> str: """ Collate a list of (query, result) tuples into a single string for LLM context. diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 9aefaf1..48e20ab 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -13,13 +13,14 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime -from typing import Literal, overload +from typing import Literal, overload, cast from bigdata_client import Bigdata from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange from bigdata_client.document import Document from bigdata_client.models.advanced_search_query import QueryComponent from bigdata_client.models.search import DocumentType, SortBy +from matplotlib.pylab import Sequence from tqdm import tqdm from bigdata_research_tools.client import bigdata_connection, init_bigdata_client @@ -30,15 +31,7 @@ send_trace, ) -NORMALIZED_DATE_RANGE = ( - list[tuple[datetime, datetime]] - | list[RollingDateRange] - | list[AbsoluteDateRange] - | list[tuple[datetime, datetime] | RollingDateRange] - | list[tuple[datetime, datetime] | AbsoluteDateRange] - | list[AbsoluteDateRange | RollingDateRange] - | list[tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange] -) +NORMALIZED_DATE_RANGE = Sequence[tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange] INPUT_DATE_RANGE = ( tuple[datetime, datetime] @@ -285,8 +278,11 @@ def get_quota_consumed(self) -> float: def normalize_date_range( date_ranges: INPUT_DATE_RANGE, ) -> NORMALIZED_DATE_RANGE: - if not isinstance(date_ranges, list): - date_ranges = [date_ranges] + if isinstance(date_ranges, (AbsoluteDateRange, RollingDateRange, tuple)): + return cast(NORMALIZED_DATE_RANGE, [date_ranges]) + if isinstance(date_ranges, Sequence): + if all(isinstance(dr, (tuple, AbsoluteDateRange, RollingDateRange)) for dr in date_ranges): + return list(date_ranges) return date_ranges @@ -357,7 +353,7 @@ def run_search( If `only_results` is False, returns a mapping of the tuple of search query and date range to the list of the corresponding search results. """ - date_ranges = normalize_date_range(date_ranges) + date_ranges = list(normalize_date_range(date_ranges)) if isinstance(date_ranges[0], tuple) or isinstance(date_ranges[0], list): date_ranges.sort(key=lambda x: x[0]) From 559ac3a8d6f4ff91ca8fc9c14c944d880b15b9f4 Mon Sep 17 00:00:00 2001 From: jaldana Date: Wed, 19 Nov 2025 10:40:24 +0100 Subject: [PATCH 10/14] Format and linting --- examples/grounded_mindmaps.py | 13 +-- src/bigdata_research_tools/llm/bedrock.py | 5 +- .../mindmap/mindmap_generator.py | 89 +++++++++++-------- src/bigdata_research_tools/search/search.py | 16 ++-- .../visuals/mindmap_visuals.py | 3 +- 5 files changed, 71 insertions(+), 55 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index f9adf78..a600f39 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -2,7 +2,6 @@ from dotenv import load_dotenv -from bigdata_research_tools.mindmap.mindmap import MindMap from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap @@ -91,7 +90,7 @@ def test_refined_mindmap2( focus=focus, main_theme=main_theme, initial_mindmap=base_mindmap, - date_range = ("2025-10-01", "2025-10-31"), + date_range=("2025-10-01", "2025-10-31"), output_dir="./refined_mindmaps", filename="refined_mindmap.json", map_type=map_type, @@ -125,10 +124,14 @@ def test_dynamic_mindmap( ("2025-10-01", "2025-10-31"), ("2025-11-01", "2025-11-30"), ], - month_names=["September_2025", "October_2025", "November_2025", ], + month_names=[ + "September_2025", + "October_2025", + "November_2025", + ], ) - logger.info("Results: %s", mindmap["base_mindmap"]['mindmap_json']) - logger.info("Results: %s", mindmap["October_2025"]['mindmap_json']) + logger.info("Results: %s", mindmap["base_mindmap"]["mindmap_json"]) + logger.info("Results: %s", mindmap["October_2025"]["mindmap_json"]) logger.info("") diff --git a/src/bigdata_research_tools/llm/bedrock.py b/src/bigdata_research_tools/llm/bedrock.py index 2107722..f099586 100644 --- a/src/bigdata_research_tools/llm/bedrock.py +++ b/src/bigdata_research_tools/llm/bedrock.py @@ -2,7 +2,6 @@ try: from boto3 import Session # ty: ignore[unresolved-import] - from botocore import BaseClient # type: ignore[unresolved-import] except ImportError: raise ImportError( "Missing optional dependency for LLM Bedrock provider, " @@ -34,7 +33,7 @@ def configure_bedrock_client(self) -> None: if not self._client: self._client = Session(**self.connection_config) - def _get_bedrock_client(self) -> BaseClient: + def _get_bedrock_client(self): if not self._client: raise NotInitializedLLMProviderError(self) return self._client.client("bedrock-runtime") @@ -195,7 +194,7 @@ def configure_bedrock_client(self) -> None: if not self._client: self._client = Session(**self.connection_config) - def _get_bedrock_client(self) -> BaseClient: + def _get_bedrock_client(self): if not self._client: raise NotInitializedLLMProviderError(self) return self._client.client("bedrock-runtime") diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 40ac243..cd82129 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -7,16 +7,12 @@ from typing import Any, Optional from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange +from bigdata_client.models.advanced_search_query import QueryComponent from bigdata_client.models.search import DocumentType, SortBy from bigdata_client.query import ( Any as BigdataAny, ) -from bigdata_client.models.search import DocumentType, SortBy -from bigdata_client.query import ( - Keyword, - Similarity, - Entity -) +from bigdata_client.query import Entity, Keyword, Similarity from tqdm import tqdm from bigdata_research_tools.client import bigdata_connection @@ -29,10 +25,7 @@ prompts_dict, save_results_to_file, ) -from bigdata_client.models.advanced_search_query import QueryComponent -from bigdata_research_tools.search.search import SEARCH_QUERY_RESULTS_TYPE - -from bigdata_research_tools.search.search import run_search, INPUT_DATE_RANGE +from bigdata_research_tools.search.search import run_search logger: Logger = getLogger(__name__) @@ -229,13 +222,13 @@ def compose_tool_call_message( focus: str, map_type: str, instructions: Optional[str], - date_range: Optional[tuple[str,str]], + date_range: Optional[tuple[str, str]], initial_mindmap: Optional[str], ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] - + tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." - + if initial_mindmap: tool_prompt += f"\nStarting from the following mind map:\n{initial_mindmap}" if date_range is not None: @@ -275,9 +268,9 @@ def send_tool_call( if response_dict["tool_calls"] is not None: tool_call_id = response_dict["id"][0] arguments = response_dict["arguments"][0] - search_list = arguments.get("search_list", []) # ty: ignore[possibly-missing-attribute] - entities_list = arguments.get("entities_list", []) # ty: ignore[possibly-missing-attribute] - keywords_list = arguments.get("keywords_list", []) # ty: ignore[possibly-missing-attribute] + search_list = arguments.get("search_list", []) # ty: ignore[possibly-missing-attribute] + entities_list = arguments.get("entities_list", []) # ty: ignore[possibly-missing-attribute] + keywords_list = arguments.get("keywords_list", []) # ty: ignore[possibly-missing-attribute] return ( tool_call_id, response_dict["tool_calls"], @@ -298,7 +291,7 @@ def compose_final_message( focus: str, map_type: str, instructions: Optional[str], - date_range: Optional[tuple[str,str]], + date_range: Optional[tuple[str, str]], tool_calls, tool_call_id, context, @@ -330,7 +323,7 @@ def compose_refinement_message( focus: str, map_type: str, instructions: Optional[str], - date_range: Optional[tuple[str,str]], + date_range: Optional[tuple[str, str]], initial_mindmap: str, context: str, tool_calls, @@ -339,7 +332,7 @@ def compose_refinement_message( enforce_structure = prompts_dict[map_type]["enforce_structure_string"] refine_prompt = f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus}.\nBased on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text.\nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}." - + if date_range is not None: refine_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" @@ -367,16 +360,25 @@ def generate_one_shot( Optionally log intermediate steps to disk. """ - messages = self.compose_base_message(main_theme=main_theme, focus=focus, map_type=map_type, instructions=instructions) + messages = self.compose_base_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + ) llm_kwargs = self.llm_model_config_base.get_llm_kwargs( remove_max_tokens=True, remove_timeout=True ) if allow_grounding: messages = self.compose_tool_call_message( - main_theme=main_theme, focus=focus, map_type=map_type, instructions=instructions, date_range=date_range, - initial_mindmap=None - ) + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=None, + ) tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( self.send_tool_call(messages, self.llm_base, llm_kwargs) ) @@ -461,7 +463,7 @@ def generate_refined( map_type=map_type, instructions=instructions, date_range=date_range, - initial_mindmap=initial_mindmap + initial_mindmap=initial_mindmap, ) llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs( remove_max_tokens=True, remove_timeout=True @@ -491,7 +493,7 @@ def generate_refined( initial_mindmap=initial_mindmap, tool_calls=tool_calls, tool_call_id=tool_call_id, - context=context + context=context, ) mindmap_text = self.llm_reasoning.get_response(refinement_messages) @@ -518,7 +520,6 @@ def generate_refined( } save_results_to_file(result_dict, output_dir, filename) return result_dict - def generate_or_load_refined( self, @@ -660,10 +661,10 @@ def generate_dynamic( one_shot = self.generate_one_shot( main_theme=main_theme, focus=focus, - allow_grounding=False, - instructions=instructions, - map_type=map_type, - **llm_kwargs + allow_grounding=False, + instructions=instructions, + map_type=map_type, + **llm_kwargs, ) prev_mindmap = one_shot["mindmap_json"] print(prev_mindmap) @@ -672,7 +673,6 @@ def generate_dynamic( for i, (date_range, month_name) in enumerate( zip(month_intervals, month_names), start=0 ): - refined = self.generate_refined( main_theme=main_theme, focus=focus, @@ -716,14 +716,18 @@ def _run_and_collate_search( if date_range is None: date_range_filter = RollingDateRange.LAST_THIRTY_DAYS else: - date_range_filter = AbsoluteDateRange(start=date_range[0], end=date_range[1]) + date_range_filter = AbsoluteDateRange( + start=date_range[0], end=date_range[1] + ) if entities_list: print(f"Entities List: {entities_list}") entity_objs = [] for entity_name in entities_list: try: - suggestions = self.bigdata_connection.knowledge_graph.autosuggest(entity_name, limit=1) + suggestions = self.bigdata_connection.knowledge_graph.autosuggest( + entity_name, limit=1 + ) if suggestions: # Check if list is not empty entity = suggestions[0] entity_objs.append(entity) @@ -732,14 +736,20 @@ def _run_and_collate_search( except Exception as e: print(f"Warning: Autosuggest failed for '{entity_name}': {e}") continue - - confirmed_entities = [entity.id for entity, orig_str in zip(entity_objs, entities_list) if entity.name.lower() in orig_str.lower() or orig_str.lower() in entity.name.lower()] + + confirmed_entities = [ + entity.id + for entity, orig_str in zip(entity_objs, entities_list) + if entity.name.lower() in orig_str.lower() + or orig_str.lower() in entity.name.lower() + ] if confirmed_entities: - entities = BigdataAny([Entity(entity) for entity in confirmed_entities]) else: entities = None - print(f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}") + print( + f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}" + ) else: entities = None if keywords_list: @@ -748,7 +758,9 @@ def _run_and_collate_search( else: keywords = None - queries: list[QueryComponent] = [Similarity(sentence) for sentence in search_list] + queries: list[QueryComponent] = [ + Similarity(sentence) for sentence in search_list + ] if entities: queries = [query & entities for query in queries] if keywords: @@ -778,7 +790,6 @@ def collate_results(self, results: dict) -> str: """ doctexts = [] for (text_query, date_range), result in results.items(): - dictitem = text_query.to_dict() if dictitem["type"] == "similarity": sentence = dictitem["value"] diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 48e20ab..941f354 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -13,14 +13,13 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime -from typing import Literal, overload, cast +from typing import Literal, Sequence, cast, overload from bigdata_client import Bigdata from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange from bigdata_client.document import Document from bigdata_client.models.advanced_search_query import QueryComponent from bigdata_client.models.search import DocumentType, SortBy -from matplotlib.pylab import Sequence from tqdm import tqdm from bigdata_research_tools.client import bigdata_connection, init_bigdata_client @@ -31,7 +30,9 @@ send_trace, ) -NORMALIZED_DATE_RANGE = Sequence[tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange] +NORMALIZED_DATE_RANGE = Sequence[ + tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange +] INPUT_DATE_RANGE = ( tuple[datetime, datetime] @@ -40,9 +41,7 @@ | NORMALIZED_DATE_RANGE ) -SEARCH_QUERY_RESULTS_TYPE = dict[ - tuple[QueryComponent, str], list[Document] -] +SEARCH_QUERY_RESULTS_TYPE = dict[tuple[QueryComponent, str], list[Document]] REQUESTS_PER_MINUTE_LIMIT = 300 MAX_WORKERS = 4 @@ -281,7 +280,10 @@ def normalize_date_range( if isinstance(date_ranges, (AbsoluteDateRange, RollingDateRange, tuple)): return cast(NORMALIZED_DATE_RANGE, [date_ranges]) if isinstance(date_ranges, Sequence): - if all(isinstance(dr, (tuple, AbsoluteDateRange, RollingDateRange)) for dr in date_ranges): + if all( + isinstance(dr, (tuple, AbsoluteDateRange, RollingDateRange)) + for dr in date_ranges + ): return list(date_ranges) return date_ranges diff --git a/src/bigdata_research_tools/visuals/mindmap_visuals.py b/src/bigdata_research_tools/visuals/mindmap_visuals.py index c09b468..4b14227 100644 --- a/src/bigdata_research_tools/visuals/mindmap_visuals.py +++ b/src/bigdata_research_tools/visuals/mindmap_visuals.py @@ -37,9 +37,10 @@ maintaining visual hierarchy and readability. """ -import matplotlib from typing import Any +import matplotlib + matplotlib.use("Agg") # Use non-interactive backend import math import os From c4a8e97bd71aa2408520d9c2822f3137c9ee7ef5 Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Wed, 19 Nov 2025 15:18:03 +0000 Subject: [PATCH 11/14] grounding for workflows and motivation issues --- examples/risk_analyzer.py | 3 +- examples/thematic_screener.py | 3 +- src/bigdata_research_tools/llm/base.py | 1 + src/bigdata_research_tools/llm/openai.py | 4 +- src/bigdata_research_tools/mindmap/mindmap.py | 2 - .../mindmap/mindmap_generator.py | 63 ++++++++++++----- .../mindmap/mindmap_utils.py | 70 +++++++++++++++++++ .../portfolio/motivation.py | 7 +- .../workflows/risk_analyzer.py | 26 +++++-- .../workflows/thematic_screener.py | 21 ++++-- 10 files changed, 165 insertions(+), 35 deletions(-) diff --git a/examples/risk_analyzer.py b/examples/risk_analyzer.py index 7edb2f3..575260b 100644 --- a/examples/risk_analyzer.py +++ b/examples/risk_analyzer.py @@ -26,7 +26,7 @@ def risk_analyzer_example( analyzer = RiskAnalyzer( main_theme=risk_scenario, - companies=companies, + companies=companies[:3], start_date="2025-01-01", end_date="2025-01-31", keywords=keywords, @@ -34,6 +34,7 @@ def risk_analyzer_example( control_entities=control_entities, focus=focus, # Optional focus to narrow the theme, llm_model_config=llm_model_config, + ground_mindmap=False ) class PrintObserver(Observer): diff --git a/examples/thematic_screener.py b/examples/thematic_screener.py index dacc07d..4ed41e7 100644 --- a/examples/thematic_screener.py +++ b/examples/thematic_screener.py @@ -30,6 +30,7 @@ def thematic_screener_example( end_date="2024-02-28", document_type=DocumentType.TRANSCRIPTS, fiscal_year=2024, + ground_mindmap=True ) class PrintObserver(Observer): @@ -59,7 +60,7 @@ def update(self, message: OberserverNotification): x = thematic_screener_example( "Chip Manufacturers", export_path=str(output_path), - llm_model_config="openai::gpt-5-mini", + llm_model_config="openai::gpt-4o-mini", ) custom_config = { "company_column": "Company", diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 4979064..92649f3 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -59,6 +59,7 @@ def validate_reasoning_config(self): self.reasoning_effort = ( self.reasoning_effort if self.reasoning_effort is not None else "high" ) + self.max_completion_tokens = 1000 if self.temperature is not None: warnings.warn( "The selected model does not support temperature settings. " diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 54b7116..e893921 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -16,7 +16,8 @@ LLMProvider, NotInitializedLLMProviderError, ) - +from logging import Logger, getLogger +logger: Logger = getLogger(__name__) class AsyncOpenAIProvider(AsyncLLMProvider): def __init__(self, model: str, **connection_config): @@ -161,6 +162,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: chat_completion = self._client.chat.completions.create( messages=chat_history, model=self.model, **kwargs ) + logger.info(f"Chat Completion {chat_completion}") return chat_completion.choices[0].message.content diff --git a/src/bigdata_research_tools/mindmap/mindmap.py b/src/bigdata_research_tools/mindmap/mindmap.py index f466196..2dc2e65 100644 --- a/src/bigdata_research_tools/mindmap/mindmap.py +++ b/src/bigdata_research_tools/mindmap/mindmap.py @@ -526,7 +526,6 @@ def get_default_tree_config(llm_model: str) -> LLMConfig: model=llm_model, reasoning_effort="high", seed=42, - max_completion_tokens=300, response_format={"type": "json_object"}, ) else: @@ -536,7 +535,6 @@ def get_default_tree_config(llm_model: str) -> LLMConfig: top_p=1, frequency_penalty=0, presence_penalty=0, - max_completion_tokens=300, seed=42, response_format={"type": "json_object"}, ) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index cd82129..0f642cf 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -92,6 +92,8 @@ def __init__( self.llm_model_config_base = LLMConfig(**llm_model_config_base) elif isinstance(llm_model_config_base, str): self.llm_model_config_base = get_default_tree_config(llm_model_config_base) + else: + self.llm_model_config_base = llm_model_config_base if isinstance(llm_model_config_reasoning, dict): self.llm_model_config_reasoning = LLMConfig(**llm_model_config_reasoning) @@ -99,13 +101,14 @@ def __init__( self.llm_model_config_reasoning = get_default_tree_config( llm_model_config_reasoning ) + else: + self.llm_model_config_reasoning = llm_model_config_reasoning - print(self.llm_model_config_base) self.llm_base = LLMEngine( model=self.llm_model_config_base.model, **self.llm_model_config_base.connection_config, ) - print(self.llm_model_config_reasoning) + self.llm_reasoning = LLMEngine( model=self.llm_model_config_reasoning.model, **self.llm_model_config_reasoning.connection_config, @@ -201,6 +204,9 @@ def compose_base_message( self, main_theme: str, focus: str, map_type: str, instructions: Optional[str] ) -> list: # Explicit, step-by-step prompt (robust, as in working repo, minus Keywords) + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] messages = [ { @@ -214,6 +220,7 @@ def compose_base_message( ), }, ] + return messages def compose_tool_call_message( @@ -227,6 +234,9 @@ def compose_tool_call_message( ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." if initial_mindmap: @@ -298,8 +308,14 @@ def compose_final_message( ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + final_prompt = f"{instructions} {focus}. \nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}" + if date_range is not None: + final_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + final_message = [ { "role": "system", @@ -331,6 +347,9 @@ def compose_refinement_message( ) -> list: enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + refine_prompt = f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus}.\nBased on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text.\nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}." if date_range is not None: @@ -353,7 +372,7 @@ def generate_one_shot( instructions: Optional[str] = None, date_range: Optional[tuple[str, str]] = None, map_type: str = "risk", - ) -> dict[str, Any]: + ) -> tuple[MindMap, dict]: """ Generate a mind map in one LLM call, optionally allowing the LLM to request grounding. If allow_grounding is True, use the specified grounding_method ("tool_call" or "chat"). @@ -403,7 +422,7 @@ def generate_one_shot( theme_tree = self._parse_llm_to_themetree(mindmap_text) df = self._themetree_to_dataframe(theme_tree) - return { + return theme_tree, { "mindmap_text": mindmap_text, "mindmap_df": df, "mindmap_json": theme_tree.to_json(), ##where does this come from? @@ -418,7 +437,7 @@ def generate_one_shot( mindmap_text ) ## check if correct df = format_mindmap_to_dataframe(mindmap_text) - return { + return None, { "mindmap_text": mindmap_text, "mindmap_df": df, "mindmap_json": theme_tree.to_json(), @@ -429,7 +448,7 @@ def generate_one_shot( theme_tree = self._parse_llm_to_themetree(mindmap_text) df = self._themetree_to_dataframe(theme_tree) - return { + return theme_tree, { "mindmap_text": mindmap_text, "mindmap_tree": theme_tree, "mindmap_json": theme_tree.to_json(), @@ -451,7 +470,7 @@ def generate_refined( date_range: Optional[tuple[str, str]] = None, chunk_limit: int = 20, **llm_kwargs, - ) -> dict[str, Any]: + ) -> tuple[MindMap, dict]: """ Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. Optionally log intermediate steps to disk. @@ -507,7 +526,7 @@ def generate_refined( "search_context": context, } save_results_to_file(result_dict, output_dir, filename) - return result_dict + return theme_tree, result_dict else: mindmap_text = search_list if isinstance(search_list, str) else "" df = format_mindmap_to_dataframe(mindmap_text) @@ -519,7 +538,7 @@ def generate_refined( "search_context": "", } save_results_to_file(result_dict, output_dir, filename) - return result_dict + return None, result_dict def generate_or_load_refined( self, @@ -535,7 +554,7 @@ def generate_or_load_refined( output_dir: str = "./bootstrapped_mindmaps", filename: str = "refined_mindmap", i: int = 0, - ): + ) -> dict: if f"{filename}_{i}.json" in os.listdir(output_dir): result = load_results_from_file(output_dir, f"{filename}_{i}.json") print(f"Loaded existing result for {filename}_{i}.json") @@ -557,7 +576,7 @@ def generate_or_load_refined( # save_results_to_file(result, output_dir, ) except Exception as e: print(e) - result = self.generate_refined( + _, result = self.generate_refined( instructions=instructions, focus=focus, main_theme=main_theme, @@ -585,7 +604,7 @@ def bootstrap_refined( filename: str = "refined_mindmap", n_elements: int = 50, max_workers: int = 10, - ): + ) -> dict: """ Generate multiple refined mindmaps in parallel using ThreadPoolExecutor. @@ -650,15 +669,16 @@ def generate_dynamic( map_type: str = "risk", output_dir: str = "./dynamic_mindmaps", **llm_kwargs, - ) -> dict[str, dict[str, Any]]: + ) -> tuple[dict[str,MindMap], dict]: """ Dynamic/iterative mind map generation over time intervals. Returns a list of dicts, one per interval. Each step: generate/refine mind map for the given interval, grounded in search results for that period. """ results = {} + mind_map_objs = {} # Step 1: Generate initial mind map for t0 - one_shot = self.generate_one_shot( + one_shot_map, one_shot_dict = self.generate_one_shot( main_theme=main_theme, focus=focus, allow_grounding=False, @@ -666,14 +686,14 @@ def generate_dynamic( map_type=map_type, **llm_kwargs, ) - prev_mindmap = one_shot["mindmap_json"] - print(prev_mindmap) - results["base_mindmap"] = one_shot + prev_mindmap = one_shot_dict["mindmap_json"] + mind_map_objs["base_mindmap"] = one_shot_map + results["base_mindmap"] = one_shot_dict # Step 2: For each subsequent interval, refine using previous mind map and new search, including starting month for i, (date_range, month_name) in enumerate( zip(month_intervals, month_names), start=0 ): - refined = self.generate_refined( + refined_map, refined = self.generate_refined( main_theme=main_theme, focus=focus, initial_mindmap=prev_mindmap, @@ -689,8 +709,9 @@ def generate_dynamic( ) results[month_name] = refined + mind_map_objs[month_name] = refined_map prev_mindmap = refined["mindmap_json"] - return results + return mind_map_objs, results def _run_and_collate_search( self, @@ -758,6 +779,8 @@ def _run_and_collate_search( else: keywords = None + print(f"Searching with sentences: {search_list}") + queries: list[QueryComponent] = [ Similarity(sentence) for sentence in search_list ] @@ -793,6 +816,8 @@ def collate_results(self, results: dict) -> str: dictitem = text_query.to_dict() if dictitem["type"] == "similarity": sentence = dictitem["value"] + else: + sentence = "" docstr = f"###Query: {sentence}\n ### Results:\n" for doc in result: headline = getattr(doc, "headline", "No headline") diff --git a/src/bigdata_research_tools/mindmap/mindmap_utils.py b/src/bigdata_research_tools/mindmap/mindmap_utils.py index 4f09b07..af436aa 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_utils.py +++ b/src/bigdata_research_tools/mindmap/mindmap_utils.py @@ -8,6 +8,28 @@ "theme": { "qualifier": "Main Theme", "user_prompt_message": "Your given Theme is: {main_theme}", + "default_instructions": ("Forget all previous prompts." + "You are assisting a professional analyst tasked with creating a screener to measure the impact of the theme {main_theme} on companies." + "Your objective is to generate a comprehensive tree structure of distinct sub-themes that will guide the analyst's research process." + "Follow these steps strictly:" + "1. **Understand the Core Theme {main_theme}**:" + " - The theme {main_theme} is a central concept. All components are essential for a thorough understanding." + "2. **Create a Taxonomy of Sub-themes for {main_theme}**:" + " - Decompose the main theme {main_theme} into concise, focused, and self-contained sub-themes." + " - Each sub-theme should represent a singular, concise, informative, and clear aspect of the main theme." + " - Expand the sub-theme to be relevant for the {main_theme}: a single word is not informative enough." + " - Prioritize clarity and specificity in your sub-themes." + " - Avoid repetition and strive for diverse angles of exploration." + " - Provide a comprehensive list of potential sub-themes." + "3. **Iterate Based on the Analyst's Focus {analyst_focus}**:" + " - If no specific {analyst_focus} is provided, transition directly to formatting the JSON response." + "4. **Format Your Response as a JSON Object**:" + " - Each node in the JSON object must include:" + " - `node`: an integer representing the unique identifier for the node." + " - `label`: a string for the name of the sub-theme." + " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme {main_theme}." + " - For the node referring to the first node {main_theme}, just define briefly in maximum 15 words the theme {main_theme}." + " - `children`: an array of child nodes."), "enforce_structure_string": ( """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" "- `node`: an integer representing the unique identifier for the node.\n" @@ -43,6 +65,54 @@ "risk": { "qualifier": "Risk Scenario", "user_prompt_message": "Your given Risk Scenario is: {main_theme}", + "default_instructions": ("Forget all previous prompts." + "You are assisting a professional risk analyst tasked with creating a taxonomy to classify the impact of the Risk Scenario '**{main_theme}**' on companies." + "Your objective is to generate a **comprehensive tree structure** that maps the **risk spillovers** stemming from the Risk Scenario '**{main_theme}**', and generates related sub-scenarios." + "Key Instructions:" + "1. **Understand the Risk Scenario: '{main_theme}'**:" + " - The Risk Scenario '**{main_theme}**' represents a central, multifaceted concept that may be harmful or beneficial to firms." + " - Your task is to identify how the Risk Scenario impacts firms through various **risk spillovers** and transmission channels." + " - Summarize the Risk Scenario '**{main_theme}**' in a **short list of essential keywords**." + " - The keyword list should be short (1-2 keywords). Avoid unnecessary, unmentioned, indirectly inferred, or redundant keywords." + "2. **Create a Tree Structure for Risk Spillovers and Sub-Scenarios**:" + " - Decompose the Risk Scenario into **distinct, focused, and self-contained risk spillovers**." + " - Each risk spillover must represent a **specific risk channel** through which firms are exposed to as a consequence of the Risk Scenario." + " - Label each **primary node** in the tree explicitly as a 'Risk' in the `Label` field. For example:" + " - Use 'Cost Risk' instead of 'Cost Impacts'." + " - Use 'Supply Chain Risk' instead of 'Supply Chain Disruptions'." + " - Risk spillovers must:" + " - Cover a wide range of potential impacts on firms' operations, business, performance, strategy, profits, and long-term success." + " - Explore both macroeconomic and microeconomic dimensions of the Risk Scenario '**{main_theme}**' and analyze their impact on firms when relevant." + " - Microeconomic effects, such as cost of inputs, directly affect firms' operations" + " - Macroeconomic effects may affect firms revenues directly (e.g. currency fluctuations) or indirectly (e.g. economic downturns triggering lower demand)." + " - Include **direct and indirect consequences** of the main scenario." + " - Represent **dimensions of risk** that firms must monitor or mitigate." + " - NOT overlap." + " - Independently identify the most relevant spillovers based on the Risk Scenario '**{main_theme}**', without limiting to predefined categories." + "3. **Generate Sub-Scenarios for Each Risk Spillover**:" + " - For each risk spillover, identify **specific sub-scenarios** that will arise as a consequence of the Risk Scenario '**{main_theme}**'." + " - All sub-scenarios must:" + " - Be **concise and descriptive sentences**, clearly stating how the sub-scenario is an event caused by the main scenario." + " - **Explicitly include ALL core concepts and keywords** from the main scenario, including specific geographical locations or temporal details, in every sentence in order to ensure clarity and relevance towards the main scenario." + " - Integrate the Risk Scenario in a natural way, avoiding repetitive or mechanical structures." + " - Not exceed 15 words." + " - Sub-scenarios MUST be mutually exclusive: they CANNOT overlap neither within nor across branches of the tree." + " - Do NOT combine multiple sub-scenarios in a single label." + " - Sub-Scenarios have to be consistent with the parent Risk Spillover (e.g. Market Access related sub-scenarios have to belong to the Market Access Risk node)." + " - Generate 3 OR MORE sub-scenarios for each risk spillover." + " - Generate a short label for each subscenario." + "4. **Iterate Based on the Analyst's Focus: '{analyst_focus}'**:" + " - After generating the initial tree structure, use the analyst's focus ('{analyst_focus}') to:" + " - Identify **missing branches** or underexplored areas of the tree." + " - Add new risk spillovers or sub-scenarios that align with the analyst's focus." + " - Ensure that sub-scenarios ALWAYS include ALL core components of the Risk Scenario and are formulated as natural sentences." + " - Ensure that sub-scenarios DO NOT overlap within and across risk spillovers." + " - Ensure that sub-scenarios belong to the correct Risk Spillover." + " - If the analyst focus is empty, skip this step." + " - If you don't understand the analyst focus ('{analyst_focus}'), ask an open-ended question to the analyst." + "5. **Review and Expand the Tree for Missing Risks**:" + " - After incorporating the analyst's focus, review the tree structure to ensure it includes a **broad range of risks** and sub-scenarios." + " - Add any missing risks or sub-scenarios to the tree."), "enforce_structure_string": ( """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" " - `node`: an integer representing the unique identifier for the node.\n" diff --git a/src/bigdata_research_tools/portfolio/motivation.py b/src/bigdata_research_tools/portfolio/motivation.py index cd05b1f..f8de4fa 100644 --- a/src/bigdata_research_tools/portfolio/motivation.py +++ b/src/bigdata_research_tools/portfolio/motivation.py @@ -36,6 +36,8 @@ def __init__( else: self.llm_model_config = llm_model_config + logger.info(f"LLM Config {self.llm_model_config}") + self.llm_engine = LLMEngine( model=self.llm_model_config.model, **self.llm_model_config.connection_config ) @@ -43,8 +45,9 @@ def __init__( def _get_default_model_config(self, model: str) -> LLMConfig: """Get default LLM model configuration.""" if any(rm in model for rm in REASONING_MODELS): + print(model) return LLMConfig( - model=model, reasoning_effort="high", seed=42, max_completion_tokens=300 + model=model, reasoning_effort="high", seed=42, max_completion_tokens=1000, ) else: return LLMConfig( @@ -141,6 +144,8 @@ def query_llm_for_motivation(self, prompt: str) -> str: ), ) + logger.info(f"Motivation {motivation}") + return motivation.strip() def generate_company_motivations( diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index 5ba8882..9ea06c4 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -13,6 +13,7 @@ MindMap, generate_risk_tree, ) +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -44,6 +45,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str = "", + ground_mindmap: bool = False, ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -79,6 +81,7 @@ def __init__( self.sources = sources self.rerank_threshold = rerank_threshold self.focus = focus + self.ground_mindmap = ground_mindmap if isinstance(llm_model_config, dict): self.llm_model_config = LLMConfig(**llm_model_config) @@ -87,6 +90,9 @@ def __init__( elif isinstance(llm_model_config, LLMConfig): self.llm_model_config = llm_model_config + logger.info(f"LLM Config {self.llm_model_config}") + logger.info(f"LLM Config {type(self.llm_model_config)}") + def create_taxonomy(self): """Create a risk taxonomy based on the main theme and focus. Returns: @@ -95,11 +101,19 @@ def create_taxonomy(self): List[str]: A list of terminal labels for the risk categories. """ - risk_tree = generate_risk_tree( - main_theme=self.main_theme, - focus=self.focus, - llm_model_config=self.llm_model_config, - ) + # risk_tree = generate_risk_tree( + # main_theme=self.main_theme, + # focus=self.focus, + # llm_model_config=self.llm_model_config, + # ) + + mindmap_generator = MindMapGenerator(llm_model_config_base=self.llm_model_config) + risk_tree, _ = mindmap_generator.generate_one_shot(main_theme = self.main_theme, + focus = self.focus, + allow_grounding = self.ground_mindmap, + instructions = None, + date_range = None, + map_type = "risk") risk_summaries = risk_tree.get_terminal_summaries() terminal_labels = risk_tree.get_terminal_labels() @@ -257,6 +271,7 @@ def generate_results( df_industry = get_scored_df( df_labeled, index_columns=["Industry"], pivot_column="Sub-Scenario" ) + logger.info(f"LLM CONFIG {self.llm_model_config}") motivation_generator = Motivation(llm_model_config=self.llm_model_config) motivation_df = motivation_generator.generate_company_motivations( df=df_labeled.rename(columns={"Sub-Scenario": "Theme"}), @@ -264,6 +279,7 @@ def generate_results( word_range=word_range, use_case=MotivationType.RISK_ANALYZER, ) + print(motivation_df) return df_company, df_industry, motivation_df diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index f33b187..e813c19 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -10,6 +10,7 @@ from bigdata_research_tools.labeler.screener_labeler import ScreenerLabeler from bigdata_research_tools.llm.base import LLMConfig from bigdata_research_tools.mindmap.mindmap import generate_theme_tree +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -39,6 +40,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str | None = None, + ground_mindmap: bool = None ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -84,6 +86,7 @@ def __init__( self.sources = sources self.rerank_threshold = rerank_threshold self.focus = focus or "" + self.ground_mindmap = ground_mindmap if isinstance(llm_model_config, dict): self.llm_model_config = LLMConfig(**llm_model_config) elif isinstance(llm_model_config, str): @@ -135,11 +138,19 @@ def screen_companies( try: self.notify_observers("Generating thematic tree") - theme_tree = generate_theme_tree( - main_theme=self.main_theme, - focus=self.focus, - llm_model_config=self.llm_model_config, - ) + # theme_tree = generate_theme_tree( + # main_theme=self.main_theme, + # focus=self.focus, + # llm_model_config=self.llm_model_config, + # ) + + mindmap_generator = MindMapGenerator(llm_model_config_base=self.llm_model_config) + theme_tree, _ = mindmap_generator.generate_one_shot(main_theme = self.main_theme, + focus = self.focus, + allow_grounding = self.ground_mindmap, + instructions = None, + date_range = None, + map_type = "theme") theme_summaries = theme_tree.get_terminal_summaries() terminal_labels = theme_tree.get_terminal_labels() From d35ff8512b23062ac2b43998f712d99262980722 Mon Sep 17 00:00:00 2001 From: Alessandro Bouchs Date: Wed, 19 Nov 2025 16:17:41 +0000 Subject: [PATCH 12/14] removed max tokens for reasoning models --- examples/risk_analyzer.py | 2 +- src/bigdata_research_tools/llm/base.py | 2 +- src/bigdata_research_tools/portfolio/motivation.py | 7 +------ src/bigdata_research_tools/workflows/risk_analyzer.py | 5 +---- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/examples/risk_analyzer.py b/examples/risk_analyzer.py index 575260b..ac44ff7 100644 --- a/examples/risk_analyzer.py +++ b/examples/risk_analyzer.py @@ -26,7 +26,7 @@ def risk_analyzer_example( analyzer = RiskAnalyzer( main_theme=risk_scenario, - companies=companies[:3], + companies=companies, start_date="2025-01-01", end_date="2025-01-31", keywords=keywords, diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 92649f3..d919ae1 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -59,7 +59,7 @@ def validate_reasoning_config(self): self.reasoning_effort = ( self.reasoning_effort if self.reasoning_effort is not None else "high" ) - self.max_completion_tokens = 1000 + self.max_completion_tokens = None if self.temperature is not None: warnings.warn( "The selected model does not support temperature settings. " diff --git a/src/bigdata_research_tools/portfolio/motivation.py b/src/bigdata_research_tools/portfolio/motivation.py index f8de4fa..28c4a2c 100644 --- a/src/bigdata_research_tools/portfolio/motivation.py +++ b/src/bigdata_research_tools/portfolio/motivation.py @@ -36,8 +36,6 @@ def __init__( else: self.llm_model_config = llm_model_config - logger.info(f"LLM Config {self.llm_model_config}") - self.llm_engine = LLMEngine( model=self.llm_model_config.model, **self.llm_model_config.connection_config ) @@ -45,9 +43,8 @@ def __init__( def _get_default_model_config(self, model: str) -> LLMConfig: """Get default LLM model configuration.""" if any(rm in model for rm in REASONING_MODELS): - print(model) return LLMConfig( - model=model, reasoning_effort="high", seed=42, max_completion_tokens=1000, + model=model, reasoning_effort="high", seed=42, max_completion_tokens=None, ) else: return LLMConfig( @@ -144,8 +141,6 @@ def query_llm_for_motivation(self, prompt: str) -> str: ), ) - logger.info(f"Motivation {motivation}") - return motivation.strip() def generate_company_motivations( diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index 9ea06c4..2af4d86 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -90,9 +90,6 @@ def __init__( elif isinstance(llm_model_config, LLMConfig): self.llm_model_config = llm_model_config - logger.info(f"LLM Config {self.llm_model_config}") - logger.info(f"LLM Config {type(self.llm_model_config)}") - def create_taxonomy(self): """Create a risk taxonomy based on the main theme and focus. Returns: @@ -271,7 +268,7 @@ def generate_results( df_industry = get_scored_df( df_labeled, index_columns=["Industry"], pivot_column="Sub-Scenario" ) - logger.info(f"LLM CONFIG {self.llm_model_config}") + motivation_generator = Motivation(llm_model_config=self.llm_model_config) motivation_df = motivation_generator.generate_company_motivations( df=df_labeled.rename(columns={"Sub-Scenario": "Theme"}), From eda63c91ba5b9a455d5f51d502d2aa408fd51cc4 Mon Sep 17 00:00:00 2001 From: jaldana Date: Wed, 19 Nov 2025 17:26:34 +0100 Subject: [PATCH 13/14] Fix type errors and formatting --- examples/grounded_mindmaps.py | 8 +-- examples/risk_analyzer.py | 2 +- examples/thematic_screener.py | 2 +- src/bigdata_research_tools/llm/openai.py | 4 +- .../mindmap/mindmap_generator.py | 33 +++++++----- .../mindmap/mindmap_utils.py | 52 ++++++++++--------- .../portfolio/motivation.py | 5 +- .../workflows/risk_analyzer.py | 19 ++++--- .../workflows/thematic_screener.py | 21 ++++---- 9 files changed, 82 insertions(+), 64 deletions(-) diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py index a600f39..91c8e4e 100644 --- a/examples/grounded_mindmaps.py +++ b/examples/grounded_mindmaps.py @@ -29,7 +29,7 @@ def test_one_shot_mindmap( mindmap_generator = MindMapGenerator( llm_model_config_base=llm_base_config, ) - mindmap = mindmap_generator.generate_one_shot( + _, mindmap = mindmap_generator.generate_one_shot( instructions=instructions, focus=focus, main_theme=main_theme, @@ -55,7 +55,7 @@ def test_refined_mindmap( mindmap_generator = MindMapGenerator( llm_model_config_base=llm_base_config, ) - mindmap = mindmap_generator.generate_refined( + _, mindmap = mindmap_generator.generate_refined( focus=focus, main_theme=main_theme, initial_mindmap=base_mindmap, @@ -86,7 +86,7 @@ def test_refined_mindmap2( llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config, ) - mindmap = mindmap_generator.generate_refined( + _, mindmap = mindmap_generator.generate_refined( focus=focus, main_theme=main_theme, initial_mindmap=base_mindmap, @@ -115,7 +115,7 @@ def test_dynamic_mindmap( llm_model_config_base=llm_base_config, llm_model_config_reasoning=llm_reasoning_config, ) - mindmap = mindmap_generator.generate_dynamic( + _, mindmap = mindmap_generator.generate_dynamic( instructions=instructions, focus=focus, main_theme=main_theme, diff --git a/examples/risk_analyzer.py b/examples/risk_analyzer.py index ac44ff7..c14e2f6 100644 --- a/examples/risk_analyzer.py +++ b/examples/risk_analyzer.py @@ -34,7 +34,7 @@ def risk_analyzer_example( control_entities=control_entities, focus=focus, # Optional focus to narrow the theme, llm_model_config=llm_model_config, - ground_mindmap=False + ground_mindmap=False, ) class PrintObserver(Observer): diff --git a/examples/thematic_screener.py b/examples/thematic_screener.py index 4ed41e7..67d34f6 100644 --- a/examples/thematic_screener.py +++ b/examples/thematic_screener.py @@ -30,7 +30,7 @@ def thematic_screener_example( end_date="2024-02-28", document_type=DocumentType.TRANSCRIPTS, fiscal_year=2024, - ground_mindmap=True + ground_mindmap=True, ) class PrintObserver(Observer): diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index e893921..54b7116 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -16,8 +16,7 @@ LLMProvider, NotInitializedLLMProviderError, ) -from logging import Logger, getLogger -logger: Logger = getLogger(__name__) + class AsyncOpenAIProvider(AsyncLLMProvider): def __init__(self, model: str, **connection_config): @@ -162,7 +161,6 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: chat_completion = self._client.chat.completions.create( messages=chat_history, model=self.model, **kwargs ) - logger.info(f"Chat Completion {chat_completion}") return chat_completion.choices[0].message.content diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py index 0f642cf..8490290 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_generator.py +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -4,7 +4,7 @@ import re from concurrent.futures import ThreadPoolExecutor, as_completed from logging import Logger, getLogger -from typing import Any, Optional +from typing import Optional from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange from bigdata_client.models.advanced_search_query import QueryComponent @@ -205,7 +205,9 @@ def compose_base_message( ) -> list: # Explicit, step-by-step prompt (robust, as in working repo, minus Keywords) if instructions is None: - instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) enforce_structure = prompts_dict[map_type]["enforce_structure_string"] messages = [ @@ -235,7 +237,9 @@ def compose_tool_call_message( enforce_structure = prompts_dict[map_type]["enforce_structure_string"] if instructions is None: - instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." @@ -309,7 +313,9 @@ def compose_final_message( enforce_structure = prompts_dict[map_type]["enforce_structure_string"] if instructions is None: - instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) final_prompt = f"{instructions} {focus}. \nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}" @@ -348,7 +354,9 @@ def compose_refinement_message( enforce_structure = prompts_dict[map_type]["enforce_structure_string"] if instructions is None: - instructions = prompts_dict[map_type]["default_instructions"].format(main_theme=main_theme, analyst_focus=focus) + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) refine_prompt = f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus}.\nBased on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text.\nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}." @@ -437,7 +445,7 @@ def generate_one_shot( mindmap_text ) ## check if correct df = format_mindmap_to_dataframe(mindmap_text) - return None, { + return MindMap("", 0), { "mindmap_text": mindmap_text, "mindmap_df": df, "mindmap_json": theme_tree.to_json(), @@ -470,7 +478,7 @@ def generate_refined( date_range: Optional[tuple[str, str]] = None, chunk_limit: int = 20, **llm_kwargs, - ) -> tuple[MindMap, dict]: + ) -> tuple[MindMap | None, dict]: """ Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. Optionally log intermediate steps to disk. @@ -560,7 +568,7 @@ def generate_or_load_refined( print(f"Loaded existing result for {filename}_{i}.json") else: try: - result = self.generate_refined( + _, result = self.generate_refined( instructions=instructions, focus=focus, main_theme=main_theme, @@ -574,8 +582,7 @@ def generate_or_load_refined( filename=f"{filename}_{i}.json", ) # save_results_to_file(result, output_dir, ) - except Exception as e: - print(e) + except Exception: _, result = self.generate_refined( instructions=instructions, focus=focus, @@ -586,7 +593,7 @@ def generate_or_load_refined( output_dir=output_dir, filename=f"{filename}_{i}.json", ) - # save_results_to_file(result, output_dir, f"{filename}_{i}.json") + return result def bootstrap_refined( @@ -604,7 +611,7 @@ def bootstrap_refined( filename: str = "refined_mindmap", n_elements: int = 50, max_workers: int = 10, - ) -> dict: + ) -> list[dict]: """ Generate multiple refined mindmaps in parallel using ThreadPoolExecutor. @@ -669,7 +676,7 @@ def generate_dynamic( map_type: str = "risk", output_dir: str = "./dynamic_mindmaps", **llm_kwargs, - ) -> tuple[dict[str,MindMap], dict]: + ) -> tuple[dict[str, MindMap], dict]: """ Dynamic/iterative mind map generation over time intervals. Returns a list of dicts, one per interval. diff --git a/src/bigdata_research_tools/mindmap/mindmap_utils.py b/src/bigdata_research_tools/mindmap/mindmap_utils.py index af436aa..0f8e25b 100644 --- a/src/bigdata_research_tools/mindmap/mindmap_utils.py +++ b/src/bigdata_research_tools/mindmap/mindmap_utils.py @@ -8,28 +8,30 @@ "theme": { "qualifier": "Main Theme", "user_prompt_message": "Your given Theme is: {main_theme}", - "default_instructions": ("Forget all previous prompts." - "You are assisting a professional analyst tasked with creating a screener to measure the impact of the theme {main_theme} on companies." - "Your objective is to generate a comprehensive tree structure of distinct sub-themes that will guide the analyst's research process." - "Follow these steps strictly:" - "1. **Understand the Core Theme {main_theme}**:" - " - The theme {main_theme} is a central concept. All components are essential for a thorough understanding." - "2. **Create a Taxonomy of Sub-themes for {main_theme}**:" - " - Decompose the main theme {main_theme} into concise, focused, and self-contained sub-themes." - " - Each sub-theme should represent a singular, concise, informative, and clear aspect of the main theme." - " - Expand the sub-theme to be relevant for the {main_theme}: a single word is not informative enough." - " - Prioritize clarity and specificity in your sub-themes." - " - Avoid repetition and strive for diverse angles of exploration." - " - Provide a comprehensive list of potential sub-themes." - "3. **Iterate Based on the Analyst's Focus {analyst_focus}**:" - " - If no specific {analyst_focus} is provided, transition directly to formatting the JSON response." - "4. **Format Your Response as a JSON Object**:" - " - Each node in the JSON object must include:" - " - `node`: an integer representing the unique identifier for the node." - " - `label`: a string for the name of the sub-theme." - " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme {main_theme}." - " - For the node referring to the first node {main_theme}, just define briefly in maximum 15 words the theme {main_theme}." - " - `children`: an array of child nodes."), + "default_instructions": ( + "Forget all previous prompts." + "You are assisting a professional analyst tasked with creating a screener to measure the impact of the theme {main_theme} on companies." + "Your objective is to generate a comprehensive tree structure of distinct sub-themes that will guide the analyst's research process." + "Follow these steps strictly:" + "1. **Understand the Core Theme {main_theme}**:" + " - The theme {main_theme} is a central concept. All components are essential for a thorough understanding." + "2. **Create a Taxonomy of Sub-themes for {main_theme}**:" + " - Decompose the main theme {main_theme} into concise, focused, and self-contained sub-themes." + " - Each sub-theme should represent a singular, concise, informative, and clear aspect of the main theme." + " - Expand the sub-theme to be relevant for the {main_theme}: a single word is not informative enough." + " - Prioritize clarity and specificity in your sub-themes." + " - Avoid repetition and strive for diverse angles of exploration." + " - Provide a comprehensive list of potential sub-themes." + "3. **Iterate Based on the Analyst's Focus {analyst_focus}**:" + " - If no specific {analyst_focus} is provided, transition directly to formatting the JSON response." + "4. **Format Your Response as a JSON Object**:" + " - Each node in the JSON object must include:" + " - `node`: an integer representing the unique identifier for the node." + " - `label`: a string for the name of the sub-theme." + " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme {main_theme}." + " - For the node referring to the first node {main_theme}, just define briefly in maximum 15 words the theme {main_theme}." + " - `children`: an array of child nodes." + ), "enforce_structure_string": ( """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" "- `node`: an integer representing the unique identifier for the node.\n" @@ -65,7 +67,8 @@ "risk": { "qualifier": "Risk Scenario", "user_prompt_message": "Your given Risk Scenario is: {main_theme}", - "default_instructions": ("Forget all previous prompts." + "default_instructions": ( + "Forget all previous prompts." "You are assisting a professional risk analyst tasked with creating a taxonomy to classify the impact of the Risk Scenario '**{main_theme}**' on companies." "Your objective is to generate a **comprehensive tree structure** that maps the **risk spillovers** stemming from the Risk Scenario '**{main_theme}**', and generates related sub-scenarios." "Key Instructions:" @@ -112,7 +115,8 @@ " - If you don't understand the analyst focus ('{analyst_focus}'), ask an open-ended question to the analyst." "5. **Review and Expand the Tree for Missing Risks**:" " - After incorporating the analyst's focus, review the tree structure to ensure it includes a **broad range of risks** and sub-scenarios." - " - Add any missing risks or sub-scenarios to the tree."), + " - Add any missing risks or sub-scenarios to the tree." + ), "enforce_structure_string": ( """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" " - `node`: an integer representing the unique identifier for the node.\n" diff --git a/src/bigdata_research_tools/portfolio/motivation.py b/src/bigdata_research_tools/portfolio/motivation.py index 28c4a2c..4f9555e 100644 --- a/src/bigdata_research_tools/portfolio/motivation.py +++ b/src/bigdata_research_tools/portfolio/motivation.py @@ -44,7 +44,10 @@ def _get_default_model_config(self, model: str) -> LLMConfig: """Get default LLM model configuration.""" if any(rm in model for rm in REASONING_MODELS): return LLMConfig( - model=model, reasoning_effort="high", seed=42, max_completion_tokens=None, + model=model, + reasoning_effort="high", + seed=42, + max_completion_tokens=None, ) else: return LLMConfig( diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index 2af4d86..592af0e 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -11,7 +11,6 @@ from bigdata_research_tools.llm.base import LLMConfig from bigdata_research_tools.mindmap.mindmap import ( MindMap, - generate_risk_tree, ) from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation @@ -104,13 +103,17 @@ def create_taxonomy(self): # llm_model_config=self.llm_model_config, # ) - mindmap_generator = MindMapGenerator(llm_model_config_base=self.llm_model_config) - risk_tree, _ = mindmap_generator.generate_one_shot(main_theme = self.main_theme, - focus = self.focus, - allow_grounding = self.ground_mindmap, - instructions = None, - date_range = None, - map_type = "risk") + mindmap_generator = MindMapGenerator( + llm_model_config_base=self.llm_model_config + ) + risk_tree, _ = mindmap_generator.generate_one_shot( + main_theme=self.main_theme, + focus=self.focus, + allow_grounding=self.ground_mindmap, + instructions=None, + date_range=None, + map_type="risk", + ) risk_summaries = risk_tree.get_terminal_summaries() terminal_labels = risk_tree.get_terminal_labels() diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index e813c19..5eeed88 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -9,7 +9,6 @@ from bigdata_research_tools.excel import check_excel_dependencies, save_to_excel from bigdata_research_tools.labeler.screener_labeler import ScreenerLabeler from bigdata_research_tools.llm.base import LLMConfig -from bigdata_research_tools.mindmap.mindmap import generate_theme_tree from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType @@ -40,7 +39,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str | None = None, - ground_mindmap: bool = None + ground_mindmap: bool | None = None, ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -144,13 +143,17 @@ def screen_companies( # llm_model_config=self.llm_model_config, # ) - mindmap_generator = MindMapGenerator(llm_model_config_base=self.llm_model_config) - theme_tree, _ = mindmap_generator.generate_one_shot(main_theme = self.main_theme, - focus = self.focus, - allow_grounding = self.ground_mindmap, - instructions = None, - date_range = None, - map_type = "theme") + mindmap_generator = MindMapGenerator( + llm_model_config_base=self.llm_model_config + ) + theme_tree, _ = mindmap_generator.generate_one_shot( + main_theme=self.main_theme, + focus=self.focus, + allow_grounding=self.ground_mindmap if self.ground_mindmap else False, + instructions=None, + date_range=None, + map_type="theme", + ) theme_summaries = theme_tree.get_terminal_summaries() terminal_labels = theme_tree.get_terminal_labels() From 9743bf37a8e53acbb9be21117ee8cb9008a53ee4 Mon Sep 17 00:00:00 2001 From: jaldana Date: Wed, 19 Nov 2025 17:30:57 +0100 Subject: [PATCH 14/14] Simplify default values --- src/bigdata_research_tools/workflows/thematic_screener.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index 5eeed88..4573f8f 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -39,7 +39,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str | None = None, - ground_mindmap: bool | None = None, + ground_mindmap: bool = False, ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -149,7 +149,7 @@ def screen_companies( theme_tree, _ = mindmap_generator.generate_one_shot( main_theme=self.main_theme, focus=self.focus, - allow_grounding=self.ground_mindmap if self.ground_mindmap else False, + allow_grounding=self.ground_mindmap, instructions=None, date_range=None, map_type="theme",