diff --git a/.gitignore b/.gitignore index 2ce114b..6a9f5a2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ papers/ data/ demo_data/ *.html + +# Local-only development scratch notes (not tracked) +new_feature.md +docs/design/zh/next-step-architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index 482a249..8431d70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,226 +1,144 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working in this repository. ## Project Overview -QuantMind is an intelligent knowledge extraction and retrieval framework for quantitative finance. It transforms unstructured financial content into a queryable knowledge graph using a two-stage architecture: +QuantMind is an intelligent knowledge extraction and retrieval framework for quantitative +finance. As of 2026-04, it is being **repositioned as a domain library that runs on top +of OpenAI Agents SDK**, rather than as a self-contained agent framework. -**Stage 1: Knowledge Extraction** (Current Implementation) -- Source APIs → Intelligent Parser → Workflow/Agent → Structured Knowledge Base -- Components: Crawlers, Parsers, Taggers, Workflow orchestration, Storage +The pre-pivot agent runtime (`brain/`, `tools/`, `storage/`, `tagger/`, custom Tool ABC, +custom MultiStepAgent / Memory) was removed in PR #70. A full snapshot of the removed +code is preserved on the `archive/agent-runtime-final` branch on origin — reference it +if you need historical context, never resurrect it into master. -**Stage 2: Intelligent Retrieval** (Future) -- Knowledge Base → Embeddings → Solution Scenarios (DeepResearch, RAG, Data MCP) +## Target Architecture (post-migration) -## Development Commands - -### Environment Setup -```bash -# Create and activate virtual environment -uv venv -source .venv/bin/activate # macOS/Linux -.venv\Scripts\activate # Windows - -# Install dependencies -uv pip install -e . ``` - -### Code Quality -```bash -# Lint and format code -./scripts/lint.sh -# Or manually: -ruff format . -ruff check . - -# Run tests -./scripts/unittest.sh -# Or manually: -pytest tests # all tests -pytest tests/quantmind/ # new quantmind tests -pytest tests/quantmind/models/ # specific module +quantmind/ +├── flows/ # e2e pipeline functions (paper_flow, news_flow, ...) +├── knowledge/ # Pydantic schemas (KnowledgeItem subclasses: Paper, News, ...) +├── preprocess/ # fetch (arxiv/http/doi/local) + format (pdf/html/markdown) +├── mind/ # cognitive layer; mind/memory/ is the MVP (filesystem-backed) +├── configs/ # centralized cfg + input types (BaseFlowCfg + per-flow types) +├── magic.py # resolve_magic_input: natural language -> (input, cfg) +└── utils/ # logger only ``` -### QuantMind CLI Usage -```bash -# Basic extraction -quantmind extract "machine learning finance" --max-papers 10 +Key principle: QuantMind does NOT rebuild Agent runtime, lifecycle hooks, tracing, +multi-agent handoff, or tool framework. Those come from `openai-agents`. -# Full pipeline with storage -quantmind pipeline ml_pipeline "cat:q-fin.ST" --storage json --tagger rule +## Current Repository State (transitional, after PR #70) -# Search stored papers -quantmind search --categories "Machine Learning in Finance" --limit 5 +Surviving modules — these still work but will be replaced or migrated in PR2-PR4: -# System status -quantmind status +| Module | Status | Replacement | +|--------|--------|-------------| +| `quantmind/flow/` | active | `flows/` in PR4 | +| `quantmind/parsers/` | active | `preprocess/format/` in PR3 | +| `quantmind/sources/` | active | `preprocess/fetch/` in PR3 | +| `quantmind/config/` | active | `configs/` in PR2 | +| `quantmind/llm/` | active | deleted in PR4 (use SDK + `openai` directly) | +| `quantmind/models/{content,paper,analysis}.py` | active | move to `knowledge/` in PR2 | +| `quantmind/utils/logger.py` | active | permanent | -# Configuration management -quantmind config create --output config.yaml -quantmind config show -``` +## Development Commands + +### Environment -### Legacy System ```bash -# Old autoscholar system (still available) -python quant_scholar.py +uv venv +source .venv/bin/activate +uv pip install -e . ``` -## New Architecture (QuantMind v0.2.0) - -### Core Modules - -**quantmind/** - New modular architecture following Stage 1 design: +### Lint + Tests -- **sources/**: Content acquisition layer - - `base.py`: Abstract source interface - - `arxiv_source.py`: ArXiv API integration with financial focus - -- **parsers/**: Content processing layer - - `base.py`: Abstract parser interface - - `pdf_parser.py`: PDF extraction (PyMuPDF + Marker support) - -- **tagger/**: Classification and labeling layer - - `base.py`: Abstract tagger interface - - `rule_tagger.py`: Rule-based financial classification - - `llm_tagger.py`: LLM-powered advanced tagging - -- **workflow/**: Orchestration layer - - `agent.py`: Main WorkflowAgent for pipeline coordination - - `pipeline.py`: Pipeline execution with dependency management - - `tasks.py`: Task definitions (Crawl, Parse, Tag, Store) - -- **storage/**: Knowledge base layer - - `base.py`: Abstract storage interface - - `json_storage.py`: JSON file-based storage with indexing - -- **models/**: Data models - - `paper.py`: Enhanced Paper model with Pydantic validation - - `knowledge_graph.py`: Advanced graph operations - -- **config/**: Configuration management - - `settings.py`: Structured configuration with validation - -- **utils/**: Shared utilities - - `logger.py`: Consistent logging setup - -### Examples and Usage - -- **examples/quantmind/**: Complete usage examples - - `basic_usage.py`: Basic pipeline demonstration - - `config_example.py`: Configuration system demo - -### Legacy System (autoscholar/) - -Still available for backward compatibility: -- **crawler/**: Legacy crawlers -- **parser/**: Legacy parsers -- **knowledge/**: Legacy graph models -- **visualization/**: Pyvis visualizations - -## Key Dependencies - -### Core Dependencies -- Pydantic for data validation -- NetworkX for graph operations -- PyMuPDF for PDF processing -- ArXiv API client -- YAML for configuration -- Requests for HTTP operations - -### Optional Dependencies -- OpenAI API (for LLM tagger) -- CAMEL-AI (alternative LLM framework) -- Marker (AI-powered PDF parsing) -- PyVis (graph visualization) - -## Development Guidelines - -### Code Style -- Use Pydantic models for data validation -- Follow dependency injection patterns -- Use abstract base classes for extensibility -- Implement comprehensive error handling -- Write descriptive docstrings (Google style) - -### Testing -- Unit tests in `tests/quantmind/` -- Mock external dependencies -- Test both success and failure cases -- Use pytest fixtures for common setups - -### Configuration -- Use structured configuration via `quantmind.config.settings` -- Support environment variable overrides -- Validate configuration at startup -- Provide sensible defaults - -### Architecture Principles -- **Separation of Concerns**: Each component has a single responsibility -- **Dependency Injection**: Components are configurable and testable -- **Pipeline Orchestration**: Workflow management with task dependencies -- **Quality Control**: Built-in deduplication and validation -- **Extensibility**: Easy to add new sources, parsers, taggers, storage - -## Migration Notes - -When migrating from autoscholar to quantmind: -1. Use `WorkflowAgent` instead of direct crawler usage -2. Configure components via `Settings` system -3. Use the CLI for common operations -4. Take advantage of new pipeline orchestration -5. Leverage improved error handling and logging - -## User Development Guidance - -- Config should add in `quantmind/config` -- Data models should add in `quantmind/models` -- Initialize function can not use `Dict[str, Any]`, which is not type safe. -- Do not overdesign the code, just implement the basic and straightforward code, since we can always refactor the code later. -- For examples, add in `quantmind/examples`, and just demo the simple usage. (do not add too many use cases in single file) -- For tests, add in `tests/`, and inherit the `unittest.TestCase` class. - -## Tagger Module Refactoring (v0.0.1) - -### Simplified LLM Tagger Design -The tagger module has been completely refactored to eliminate over-engineering: - -**Removed Components:** -- `rule_tagger.py` - Removed rule-based tagger (obsolete in LLM era) -- `PaperTag` class - Removed complex tag objects, use simple strings -- `hierarchical_tags` feature - Removed unnecessary complexity -- `confidence_score` calculations - LLM outputs are inherently probabilistic -- Categories vs Tags distinction - Unified to use only tags - -**Simplified LLMTagger:** -- **Type-safe configuration**: Uses `LLMTaggerConfig` Pydantic model instead of `Dict[str, Any]` -- **Structured imports**: `from quantmind.config import LLMTaggerConfig` and `from quantmind.models import Paper` -- **Clean interface**: Single `config` parameter with proper type hints -- **Base tagger**: Simplified to only require `tag_paper()` and `extract_tags()` abstract methods -- **Custom instructions**: Support for user-provided instructions via `config.custom_instructions` -- **Flexible LLM support**: `config.llm_type` and `config.llm_name` for different providers -- **Base URL support**: `config.base_url` for custom API endpoints - -**Configuration Structure:** -```python -from quantmind.config import LLMTaggerConfig - -config = LLMTaggerConfig( - llm_type="openai", - llm_name="gpt-4o", - max_tags=5, - custom_instructions="Focus on trading strategies", - api_key="your-api-key", - base_url="https://custom-endpoint.com" # optional -) - -tagger = LLMTagger(config=config) +```bash +ruff format . +ruff check . +pytest tests/ ``` -**Design Principles:** -- **No over-engineering**: Simple, direct implementation -- **Type safety**: Proper Pydantic configuration models -- **User-friendly**: Clear API with sensible defaults -- **Extensible**: Easy to add new LLM providers through config -- **Maintainable**: ~290 lines vs previous 800+ lines +Pre-commit hooks (`.pre-commit-config.yaml`) run on push: trailing whitespace, EOF, +ruff, ruff-format, full pytest. Don't bypass hooks unless the user explicitly +authorizes — fix the underlying issue instead. + +## Architecture Principles + +1. **No framework, just lib** — Functions over classes; Protocol over ABC; no plugin + registries or hook discovery +2. **Pure functions** — Flows are `async def run(...)`, not classes; state passed as + args; side effects via explicit hooks +3. **Pydantic at boundaries, frozen dataclass internally** — Pydantic for anything + exposed to LLM (`output_type=`, cfg, input); frozen dataclass for internal value + types +4. **Batch is first-class** — `batch_run(flow_fn, inputs, ...)` will land in PR4 + (concurrency + error handling + progress aggregation). Users do NOT write + `asyncio.gather` boilerplate themselves +5. **Customization 3 layers** — cfg (YAML/CLI), kwargs (Python `extra_*` flow args), + building blocks (fork the flow file). Each layer has explicit extension points +6. **Observability 3 layers** — SDK auto-tracing, external processors via + `add_trace_processor()`, local trajectory archive under `/runs/` +7. **No CLI** — User-facing entry is a runbook script (5 lines of Python), not a + framework command. Magic input is the loose-input UX, resolved by an Agent +8. **Magic input first** — Users describe intent in natural language; + `magic.resolve_magic_input(...)` returns a structured `(input, cfg)` tuple + +## Conventions When Editing + +- **Schemas**: Pydantic, `extra="forbid"`, `frozen=True`. All `KnowledgeItem` + subclasses must require `as_of: datetime` (financial time-sensitivity is mandatory) +- **Configs**: Extend `BaseFlowCfg` (lands in PR2); never use `Dict[str, Any]` in + init signatures +- **Tools**: SDK's `@function_tool` decorator; do NOT subclass anything +- **Memory backends**: Implement the `Memory` Protocol with granular `tools()`, + `mcp_servers()`, `run_hooks()`, `reset()` — each may return an empty list. Do not + force MCP on every implementation +- **Tests**: Subclasses of `unittest.TestCase` in `tests//`. Mock external + dependencies; cover both success and failure paths +- **Imports**: Absolute (`from quantmind.knowledge import Paper`); no relative + imports across module boundaries + +## Communication Conventions + +- **PR descriptions and issue bodies must be written in English**, regardless of the + language of the conversation that triggered them. They are read by external audiences + (search indexers, future maintainers, contributors who don't read Chinese). +- Commit messages: English, conventional-commit style (`feat:` / `fix:` / `refactor:` / + `docs:` / `chore:` ...). +- Inline PR review comments and issue discussion threads may be in whichever language + fits the participants. + +## Things NOT to Do + +- ❌ Rebuild Agent runtime / Tool ABC / lifecycle hook abstraction +- ❌ Add a CLI (`argparse`/`typer`/`click`); users run Python runbook scripts +- ❌ Introduce class-based `BaseFlow` / plugin registry / hook discovery +- ❌ Wrap `from agents import ...` in a QuantMind-side facade — use the SDK directly +- ❌ Mix `batch_run` and `memory` (they will be mutually exclusive in MVP; see PR5) +- ❌ Use `Dict[str, Any]` in init functions; use Pydantic models +- ❌ Add hard deps on observability platforms (Langfuse / Logfire / etc.); document + integration via `add_trace_processor()` in user-facing cookbook only +- ❌ Build embedding-based memory before filesystem memory has shipped and stabilized + +## Reference Material + +- OpenAI Agents SDK docs: +- Lifecycle / RunHooks API: +- MCP integration (filesystem server): +- Tracing (auto-capture, processors, disable): +- Original SDK announcement: +- Removed agent runtime snapshot: `archive/agent-runtime-final` branch on origin + +## Roadmap (post-PR1) + +| PR | Focus | +|----|-------| +| #70 (merged or in review) | Clean removal of self-built agent runtime | +| PR2 | `knowledge/` + `configs/` skeleton | +| PR3 | `preprocess/` (fetch + format two layers) | +| PR4 | `flows/` + `paper_flow` + `batch_run` + `magic.py`; drop old `flow/` `llm/` | +| PR5 | `mind/memory/filesystem` MVP + trajectory archive | +| PR6+ | Second flow (news/earnings) / observability cookbook / longer-term modules | diff --git a/LICENSE-APACHE b/LICENSE-APACHE deleted file mode 100644 index 8893ab4..0000000 --- a/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright {yyyy} {name of copyright owner} - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/README.md b/README.md index 186dd2f..bdc9097 100644 --- a/README.md +++ b/README.md @@ -192,10 +192,10 @@ You can take [this example](examples/basic_usage.py) as a reference. - [x] Better `flow` design for user-friendly usage - [x] First production level example (Quant Paper Agent) -- [x] `tool` integration for external information & agentic capabilities -- [ ] Agentic Orchestration (`LLMFlow`) for more advanced usage +- [ ] Migrate Agent layer to OpenAI Agents SDK +- [ ] Standardize knowledge format with `knowledge/` (Pydantic-based) - [ ] Additional content sources (financial news, blogs, reports) -- [ ] Standardize the `knowledge` format (data standardization) +- [ ] Cross-step working memory (`mind/memory`) for batch document processing --- @@ -213,16 +213,13 @@ The foundation we're building today—starting with papers—will expand to enco > > ```python > # The future we are building towards -> from quantmind import KnowledgeBase, MemoryBank -> from quantmind.agents import PaperReader, NewsMonitor -> from quantmind.brain import understand, memorize, recall +> from quantmind.flows import paper_flow, batch_run +> from quantmind.knowledge import Paper +> from quantmind.mind.memory import FilesystemMemory > -> # Initialize the knowledge base -> kb = KnowledgeBase() -> kb.ingest(source="arxiv", topic="portfolio optimization") -> -> # Query for high-level insights -> insights = kb.query("latest trends in risk parity strategies") +> memory = FilesystemMemory("./mem/factor-research/") +> for arxiv_id in arxiv_ids: +> paper: Paper = await paper_flow(ArxivIdentifier(id=arxiv_id), memory=memory) > ``` This future state represents our commitment to moving beyond simple data aggregation and toward genuine machine intelligence in the financial domain. @@ -259,12 +256,9 @@ We welcome contributions of all forms, from bug reports to feature development. ### License -QuantMind is released under the MIT License—see `LICENSE` for details. Portions of the -agent tooling system are derived from Hugging Face's `smolagents` project and are -provided under the Apache License 2.0 in `LICENSE-APACHE`. +QuantMind is released under the MIT License—see `LICENSE` for details. ### ❤️ Acknowledgements - **arXiv** for providing open access to a world of research. - The **open-source community** for the tools and libraries that make this project possible. -- Hugging Face for `smolagents`, which inspired and informed our agent tooling / runtime abstractions. diff --git a/examples/basic_usage.py b/examples/basic_usage.py deleted file mode 100644 index 7ddd8fe..0000000 --- a/examples/basic_usage.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env python3 -"""Basic usage example for QuantMind Stage 1 architecture. - -This example demonstrates how to use the new QuantMind architecture to: -1. Set up sources, parsers, taggers, and storage -2. Create and execute a knowledge extraction pipeline -3. Process financial research papers from arXiv -""" - -import os -import sys -from pathlib import Path - -# Add the parent directory to the path so we can import quantmind -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from quantmind.workflow.agent import WorkflowAgent -from quantmind.sources.arxiv_source import ArxivSource -from quantmind.parsers.pdf_parser import PDFParser -from quantmind.tagger.rule_tagger import RuleTagger -from quantmind.tagger.llm_tagger import LLMTagger -from quantmind.storage.json_storage import JSONStorage -from quantmind.config.settings import create_default_config -from quantmind.utils.logger import setup_logger, get_logger - -# Set up logging -setup_logger(level=20) # INFO level -logger = get_logger(__name__) - - -def main(): - """Run the basic QuantMind usage example.""" - logger.info("Starting QuantMind basic usage example") - - # 1. Create workflow agent - agent = WorkflowAgent( - config={ - "max_workers": 2, - "retry_attempts": 2, - "timeout": 180, - "enable_deduplication": True, - } - ) - - # 2. Register components - logger.info("Registering components...") - - # Register ArXiv source - arxiv_source = ArxivSource( - config={"max_results": 50, "sort_by": "SubmittedDate"} - ) - agent.register_source("arxiv", arxiv_source) - - # Register PDF parser (optional, for full text extraction) - pdf_parser = PDFParser( - config={ - "method": "pymupdf", # Use PyMuPDF for simplicity - "download_pdfs": False, # Skip PDF download for this example - "max_file_size": 10, # MB - } - ) - agent.register_parser("pdf", pdf_parser) - - # Register rule-based tagger - rule_tagger = RuleTagger(config={"case_sensitive": False}) - agent.register_tagger("rule", rule_tagger) - - # Register LLM tagger (optional, requires OpenAI API key) - if os.getenv("OPENAI_API_KEY"): - llm_tagger = LLMTagger( - config={ - "model_type": "openai", - "model_name": "gpt-4", - "temperature": 0.0, - } - ) - agent.register_tagger("llm", llm_tagger) - logger.info("LLM tagger registered (OpenAI API key found)") - else: - logger.info("LLM tagger skipped (no OpenAI API key)") - - # Register JSON storage - json_storage = JSONStorage( - config={ - "storage_dir": "./data/quantmind_example", - "auto_backup": True, - "max_backup_count": 3, - } - ) - agent.register_storage("json", json_storage) - - # 3. Run quick extraction example - logger.info( - "Running quick extraction for machine learning in finance papers..." - ) - - try: - papers = agent.run_quick_extraction( - source_name="arxiv", - query="cat:q-fin.ST OR cat:q-fin.TR OR (machine learning AND finance)", - max_papers=10, - tagger_name="rule", - ) - - logger.info(f"Successfully extracted {len(papers)} papers") - - # 4. Display results - print("\n" + "=" * 80) - print("EXTRACTION RESULTS") - print("=" * 80) - - for i, paper in enumerate(papers, 1): - print(f"\n{i}. {paper.title}") - print( - f" Authors: {', '.join(paper.authors[:3])}{'...' if len(paper.authors) > 3 else ''}" - ) - print(f" Categories: {', '.join(paper.categories)}") - print( - f" Tags: {', '.join(paper.tags[:5])}{'...' if len(paper.tags) > 5 else ''}" - ) - print(f" ArXiv ID: {paper.arxiv_id or 'N/A'}") - print( - f" Published: {paper.published_date.strftime('%Y-%m-%d') if paper.published_date else 'N/A'}" - ) - print(f" Abstract: {paper.abstract[:200]}...") - - except Exception as e: - logger.error(f"Quick extraction failed: {e}") - return - - # 5. Create and execute a full pipeline - logger.info("\nCreating full extraction pipeline...") - - try: - pipeline = agent.create_extraction_pipeline( - name="finance_ml_pipeline", - source_name="arxiv", - query="cat:q-fin.ST AND machine learning", - max_papers=5, - tagger_name="rule", - storage_name="json", - ) - - logger.info("Executing pipeline...") - results = agent.execute_pipeline("finance_ml_pipeline") - - print("\n" + "=" * 80) - print("PIPELINE RESULTS") - print("=" * 80) - - for task_id, result in results.items(): - print(f"\nTask {task_id}: {type(result).__name__}") - if hasattr(result, "__len__"): - print(f" Results count: {len(result)}") - - # Get pipeline statistics - stats = agent.get_pipeline_status("finance_ml_pipeline") - if stats: - print(f"\nPipeline Statistics:") - print(f" Status: {stats['status']}") - print(f" Total tasks: {stats['total_tasks']}") - print(f" Duration: {stats.get('duration', 'N/A')} seconds") - print(f" Task counts: {stats['task_counts']}") - - except Exception as e: - logger.error(f"Pipeline execution failed: {e}") - return - - # 6. Storage examples - logger.info("\nTesting storage operations...") - - try: - # Get storage statistics - storage_info = json_storage.get_storage_info() - print(f"\nStorage Info:") - print(f" Type: {storage_info['type']}") - print(f" Paper count: {storage_info['paper_count']}") - - # Search examples - if storage_info["paper_count"] > 0: - # Search by category - ml_papers = json_storage.search_papers( - categories=["Machine Learning in Finance"], limit=5 - ) - print(f" ML papers found: {len(ml_papers)}") - - # Get all categories - categories = json_storage.get_categories() - print( - f" Categories: {', '.join(categories[:5])}{'...' if len(categories) > 5 else ''}" - ) - - # Get all tags - tags = json_storage.get_tags() - print( - f" Tags: {', '.join(tags[:10])}{'...' if len(tags) > 10 else ''}" - ) - - except Exception as e: - logger.error(f"Storage operations failed: {e}") - - # 7. Show execution history - history = agent.get_execution_history() - if history: - print(f"\nExecution History: {len(history)} pipeline runs") - for execution in history[-3:]: # Show last 3 - print(f" {execution['pipeline_name']}: {execution['status']}") - - print("\n" + "=" * 80) - print("Example completed successfully!") - print("Check ./data/quantmind_example/ for stored papers") - print("=" * 80) - - -if __name__ == "__main__": - main() diff --git a/examples/config/config_example.py b/examples/config/config_example.py deleted file mode 100644 index e07625f..0000000 --- a/examples/config/config_example.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Example: Using the new unified configuration system. - -This example demonstrates how to: -1. Load configuration from YAML with environment variable substitution -2. Create default configuration -3. Save configuration to YAML -4. Access configuration values -""" - -import os -from pathlib import Path - -from quantmind.config import Setting, create_default_config, load_config - - -def main(): - """Demonstrate configuration usage.""" - print("QuantMind Configuration System Example") - print("=" * 40) - - # Example 1: Create default configuration - print("\n1. Creating default configuration:") - default_setting = create_default_config() - print(f" Source type: {type(default_setting.source).__name__}") - print(f" Parser type: {type(default_setting.parser).__name__}") - print(f" Storage directory: {default_setting.storage.storage_dir}") - - # Example 2: Save default configuration to YAML - print("\n2. Saving default configuration to YAML:") - config_dir = Path("examples/config") - config_dir.mkdir(exist_ok=True) - default_config_path = config_dir / "default_config.yaml" - default_setting.save_to_yaml(default_config_path) - print(f" Saved to: {default_config_path}") - - # Example 3: Load configuration from YAML - print("\n3. Loading configuration from YAML:") - sample_config_path = config_dir / "sample_config.yaml" - - if sample_config_path.exists(): - try: - # Load configuration with environment variable substitution - setting = load_config(sample_config_path) - print(f" ✅ Loaded configuration from {sample_config_path}") - print(f" Source: {setting.source}") - print(f" Parser: {setting.parser}") - print(f" Log level: {setting.log_level}") - - # Access specific configuration values - if setting.source: - print(f" Source max_results: {setting.source.max_results}") - - if setting.parser: - print(f" Parser method: {setting.parser.method}") - - except Exception as e: - print(f" ❌ Failed to load configuration: {e}") - else: - print(f" ⚠️ Sample config not found at {sample_config_path}") - - # Example 4: Environment variable substitution - print("\n4. Environment variable substitution:") - print(" Set these environment variables to see substitution in action:") - print(" - ARXIV_MAX_RESULTS=50") - print(" - OPENAI_MODEL=gpt-3.5-turbo") - print(" - LOG_LEVEL=DEBUG") - - env_vars = ["ARXIV_MAX_RESULTS", "OPENAI_MODEL", "LOG_LEVEL"] - for var in env_vars: - value = os.getenv(var) - status = "✅ Set" if value else "❌ Not set" - print(f" {var}: {status}" + (f" = {value}" if value else "")) - - # Example 5: Direct configuration creation - print("\n5. Creating configuration programmatically:") - from quantmind.config import ArxivSourceConfig, LLMConfig, PDFParserConfig - - custom_setting = Setting( - source=ArxivSourceConfig( - max_results=50, sort_by="relevance", download_pdfs=True - ), - parser=PDFParserConfig(method="pymupdf", extract_tables=True), - llm=LLMConfig(model="gpt-4o", temperature=0.3), - log_level="DEBUG", - ) - - print(f" ✅ Created custom configuration") - print(f" Source max_results: {custom_setting.source.max_results}") - print(f" Parser method: {custom_setting.parser.method}") - print(f" LLM model: {custom_setting.llm.model}") - - -if __name__ == "__main__": - main() diff --git a/examples/config/sample_config.yaml b/examples/config/sample_config.yaml deleted file mode 100644 index 2038e36..0000000 --- a/examples/config/sample_config.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# QuantMind Configuration Example -# This file demonstrates the new unified configuration system - -# Source configuration (single instance) -source: - type: arxiv - config: - max_results: ${ARXIV_MAX_RESULTS:100} - sort_by: submittedDate - sort_order: descending - download_pdfs: true - requests_per_second: 1.0 - -# Parser configuration (single instance) -parser: - type: pdf - config: - method: pymupdf - download_pdfs: true - extract_tables: true - extract_images: false - max_file_size_mb: 50 - -# Tagger configuration (single instance) -tagger: - type: llm - config: - llm_config: - model: ${OPENAI_MODEL:gpt-4o} - api_key: ${OPENAI_API_KEY} - temperature: 0.3 - max_tokens: 5000 - max_tags: 5 - -# Storage configuration -storage: - type: local - config: - base_dir: ${DATA_DIR:./data} - -# Flow configuration (single instance) -flow: - type: qa - config: - num_questions: 5 - include_different_difficulties: true - llm_config: - model: ${OPENAI_MODEL:gpt-4o} - api_key: ${OPENAI_API_KEY} - temperature: 0.3 - max_tokens: 4000 - -# Core LLM configuration -llm: - model: ${OPENAI_MODEL:gpt-4o} - api_key: ${OPENAI_API_KEY} - temperature: 0.3 - max_tokens: 4000 - -# Global settings -log_level: ${LOG_LEVEL:INFO} diff --git a/examples/config_example.py b/examples/config_example.py deleted file mode 100644 index 34ea86b..0000000 --- a/examples/config_example.py +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python3 -"""Configuration example for QuantMind. - -This example demonstrates how to use the configuration system to set up -QuantMind components and workflows. -""" - -import os -import sys -from pathlib import Path - -# Add the parent directory to the path so we can import quantmind -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from quantmind.config.settings import ( - Settings, - create_default_config, - save_config, - load_config, -) -from quantmind.workflow.agent import WorkflowAgent -from quantmind.sources.arxiv_source import ArxivSource -from quantmind.parsers.pdf_parser import PDFParser -from quantmind.tagger.rule_tagger import RuleTagger -from quantmind.tagger.llm_tagger import LLMTagger -from quantmind.storage.json_storage import JSONStorage -from quantmind.utils.logger import setup_logger, get_logger - -# Set up logging -setup_logger() -logger = get_logger(__name__) - - -def create_sample_config(): - """Create a sample configuration for QuantMind.""" - # Start with default configuration - settings = create_default_config() - - # Customize workflow settings - settings.workflow.max_workers = 6 - settings.workflow.retry_attempts = 3 - settings.workflow.timeout = 600 # 10 minutes - settings.workflow.enable_deduplication = True - settings.workflow.quality_threshold = 0.6 - - # Configure sources - settings.sources["arxiv"].config.update( - {"max_results": 200, "rate_limit_delay": 1.0} - ) - - # Add additional source for news - from quantmind.config.settings import SourceConfig - - settings.sources["financial_news"] = SourceConfig( - name="financial_news", - type="NewsSource", - config={ - "api_key": "${NEWS_API_KEY}", - "sources": ["reuters", "bloomberg", "financial-times"], - "max_articles": 100, - }, - enabled=False, # Disabled by default - ) - - # Configure parsers - settings.parsers["pdf"].config.update( - { - "method": "marker", # Use AI-powered parsing - "download_pdfs": True, - "max_file_size": 100, - "cache_dir": "./cache/pdfs", - } - ) - - # Add web parser - from quantmind.config.settings import ParserConfig - - settings.parsers["web"] = ParserConfig( - name="web", - type="WebParser", - config={ - "user_agent": "QuantMind/1.0", - "timeout": 30, - "max_content_length": 1000000, - }, - ) - - # Configure taggers - settings.taggers["rule"].config.update( - { - "case_sensitive": False, - "custom_categories": { - "ESG Finance": ["esg", "sustainable finance", "green bonds"], - "Crypto Finance": [ - "cryptocurrency", - "bitcoin", - "blockchain", - "defi", - ], - "High Frequency Trading": [ - "hft", - "high frequency", - "microsecond", - "latency", - ], - }, - } - ) - - # Enable LLM tagger with custom configuration - settings.taggers["llm"].enabled = True - settings.taggers["llm"].config.update( - { - "model_type": "openai", - "model_name": "gpt-4", - "temperature": 0.1, - "max_tokens": 1500, - "custom_prompt_template": "financial_classification", - } - ) - - # Configure storage - settings.storages["json"].config.update( - { - "storage_dir": "./data/quantmind", - "auto_backup": True, - "max_backup_count": 10, - "compression": True, - } - ) - - # Add database storage option - from quantmind.config.settings import StorageConfig - - settings.storages["database"] = StorageConfig( - name="database", - type="DatabaseStorage", - config={ - "connection_string": "${DATABASE_URL}", - "table_prefix": "quantmind_", - "enable_full_text_search": True, - "connection_pool_size": 5, - }, - enabled=False, # Disabled by default - ) - - # Set global settings - settings.log_level = "INFO" - settings.arxiv_max_results = 500 - - return settings - - -def demonstrate_config_usage(): - """Demonstrate how to use configuration in practice.""" - logger.info("Creating sample configuration...") - - # Create configuration - settings = create_sample_config() - - # Save configuration to file - config_path = Path("./examples/quantmind/sample_config.yaml") - save_config(settings, config_path) - logger.info(f"Saved configuration to {config_path}") - - # Load configuration from file - logger.info("Loading configuration from file...") - loaded_settings = load_config(config_path) - - # Create WorkflowAgent from configuration - agent = WorkflowAgent(config=loaded_settings.workflow.__dict__) - - # Register components based on configuration - logger.info("Registering components from configuration...") - - # Register enabled sources - for name, source_config in loaded_settings.get_enabled_sources().items(): - if source_config.type == "ArxivSource": - source = ArxivSource(config=source_config.config) - agent.register_source(name, source) - logger.info(f"Registered source: {name}") - - # Register enabled parsers - for name, parser_config in loaded_settings.get_enabled_parsers().items(): - if parser_config.type == "PDFParser": - parser = PDFParser(config=parser_config.config) - agent.register_parser(name, parser) - logger.info(f"Registered parser: {name}") - - # Register enabled taggers - for name, tagger_config in loaded_settings.get_enabled_taggers().items(): - if tagger_config.type == "RuleTagger": - tagger = RuleTagger(config=tagger_config.config) - agent.register_tagger(name, tagger) - logger.info(f"Registered tagger: {name}") - elif tagger_config.type == "LLMTagger" and os.getenv("OPENAI_API_KEY"): - tagger = LLMTagger(config=tagger_config.config) - agent.register_tagger(name, tagger) - logger.info(f"Registered tagger: {name}") - - # Register enabled storages - for name, storage_config in loaded_settings.get_enabled_storages().items(): - if storage_config.type == "JSONStorage": - storage = JSONStorage(config=storage_config.config) - agent.register_storage(name, storage) - logger.info(f"Registered storage: {name}") - - # Display agent status - print("\n" + "=" * 60) - print("AGENT CONFIGURATION") - print("=" * 60) - print(f"Sources: {list(agent.sources.keys())}") - print(f"Parsers: {list(agent.parsers.keys())}") - print(f"Taggers: {list(agent.taggers.keys())}") - print(f"Storages: {list(agent.storages.keys())}") - print(f"Max workers: {agent.max_workers}") - print(f"Retry attempts: {agent.retry_attempts}") - print(f"Timeout: {agent.timeout}") - - return agent, loaded_settings - - -def show_configuration_details(settings): - """Show detailed configuration information.""" - print("\n" + "=" * 60) - print("CONFIGURATION DETAILS") - print("=" * 60) - - print(f"\nWorkflow Settings:") - print(f" Max workers: {settings.workflow.max_workers}") - print(f" Retry attempts: {settings.workflow.retry_attempts}") - print(f" Timeout: {settings.workflow.timeout}") - print(f" Deduplication: {settings.workflow.enable_deduplication}") - print(f" Quality threshold: {settings.workflow.quality_threshold}") - - print(f"\nGlobal Settings:") - print(f" Log level: {settings.log_level}") - print(f" Storage directory: {settings.storage.storage_dir}") - print(f" ArXiv max results: {settings.arxiv_max_results}") - - print(f"\nSources ({len(settings.sources)}):") - for name, source in settings.sources.items(): - status = "✓" if source.enabled else "✗" - print(f" {status} {name} ({source.type})") - - print(f"\nParsers ({len(settings.parsers)}):") - for name, parser in settings.parsers.items(): - status = "✓" if parser.enabled else "✗" - print(f" {status} {name} ({parser.type})") - - print(f"\nTaggers ({len(settings.taggers)}):") - for name, tagger in settings.taggers.items(): - status = "✓" if tagger.enabled else "✗" - print(f" {status} {name} ({tagger.type})") - - print(f"\nStorages ({len(settings.storages)}):") - for name, storage in settings.storages.items(): - status = "✓" if storage.enabled else "✗" - print(f" {status} {name} ({storage.type})") - - -def main(): - """Run the configuration example.""" - logger.info("Starting QuantMind configuration example") - - try: - # Create and demonstrate configuration - agent, settings = demonstrate_config_usage() - - # Show configuration details - show_configuration_details(settings) - - # Test a simple extraction if we have components - if agent.sources and agent.taggers: - logger.info("\nTesting configured pipeline...") - - source_name = list(agent.sources.keys())[0] - tagger_name = list(agent.taggers.keys())[0] - - try: - papers = agent.run_quick_extraction( - source_name=source_name, - query="machine learning finance", - max_papers=3, - tagger_name=tagger_name, - ) - - print( - f"\nTest extraction successful: {len(papers)} papers processed" - ) - - except Exception as e: - logger.warning(f"Test extraction failed: {e}") - - print("\n" + "=" * 60) - print("Configuration example completed successfully!") - print("Check sample_config.yaml for the generated configuration") - print("=" * 60) - - except Exception as e: - logger.error(f"Configuration example failed: {e}") - raise - - -if __name__ == "__main__": - main() diff --git a/examples/flow/01_custom_flow/.env.example b/examples/flow/01_custom_flow/.env.example deleted file mode 100644 index 52dba40..0000000 --- a/examples/flow/01_custom_flow/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Example environment file for QuantMind flows -# Copy this file to .env and fill in your actual API keys - -# OpenAI API Key (for GPT models) -OPENAI_API_KEY=sk-your-openai-api-key-here - -# Anthropic API Key (for Claude models) -ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key-here - -# Google API Key (for Gemini models) -GOOGLE_API_KEY=your-google-api-key-here - -# DeepSeek API Key (for DeepSeek models) -DEEPSEEK_API_KEY=your-deepseek-api-key-here - -# Custom environment variables (you can define your own) -MY_CUSTOM_LLM_KEY=your-custom-key-here diff --git a/examples/flow/01_custom_flow/README.md b/examples/flow/01_custom_flow/README.md deleted file mode 100644 index 1e2c0a7..0000000 --- a/examples/flow/01_custom_flow/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Custom Flow Example - -This example demonstrates how to create a simple custom flow using the new QuantMind flow architecture. - -## Structure - -```bash -examples/flow/01_custom_flow/ -├── flows/ -│ └── greeting_flow/ -│ ├── __init__.py # Module exports -│ ├── prompts.yaml # Prompt templates -│ └── flow.py # Flow implementation -├── config.yaml # Flow configuration -├── pipeline.py # Entry point -├── .env.example # Example .env file -└── README.md # This file -``` - -## Key Components - -### 1. Flow Configuration (`GreetingFlowConfig`) - -- Extends `BaseFlowConfig` -- Defines resource requirements (LLM blocks) -- Uses Pydantic models for simplicity -- Use `register_flow_config` decorator to register the flow config - -### 2. Flow Implementation (`GreetingFlow`) - -- Extends `BaseFlow` -- Implements custom `run()` method -- Direct access to LLM blocks: `self._llm_blocks["greeter"]` -- Template rendering: `self._render_prompt("template_name", **vars)` - -### 3. Prompt Templates (`prompts.yaml`) - -- Jinja2 templates with `{{ variable }}` syntax -- Separated from code for easy editing -- Loaded dynamically - -## Easy Import Structure - -With the new `__init__.py` files, you can now import flows cleanly: - -```python -# Import from flow directory -from flows.greeting_flow import GreetingFlow, GreetingFlowConfig - -# Use in major pipelines -flow = GreetingFlow(config) -``` - -## Running the Demo - -```bash -cd examples/flow/01_custom_flow -# Prepare the api-key .env file -cp .env.example .env -python pipeline.py -``` - -## Benefits Demonstrated - -1. **Simple Configuration**: No complex schemas, just resources -2. **Code-based Logic**: Python orchestration instead of config-driven -3. **Direct Access**: No unnecessary wrapper methods -4. **Template Separation**: YAML-based prompts -5. **Type Safety**: Pydantic models configuration - -This showcases the new architecture's core principle: **provide resources, implement logic in code**. diff --git a/examples/flow/01_custom_flow/config.yaml b/examples/flow/01_custom_flow/config.yaml deleted file mode 100644 index ce8691d..0000000 --- a/examples/flow/01_custom_flow/config.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Configuration for custom flow example -flows: - greeting_flow: - type: "greeting" - config: - name: "greeting_flow" - prompt_templates_path: "flows/greeting_flow/prompts.yaml" - llm_blocks: - greeter: - model: "gpt-4o-mini" - temperature: 0.7 - max_tokens: 500 - api_key: ${OPENAI_API_KEY} diff --git a/examples/flow/01_custom_flow/flows/greeting_flow/__init__.py b/examples/flow/01_custom_flow/flows/greeting_flow/__init__.py deleted file mode 100644 index 5e21a8b..0000000 --- a/examples/flow/01_custom_flow/flows/greeting_flow/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Greeting flow module.""" - -from .flow import GreetingFlow, GreetingFlowConfig - -__all__ = ["GreetingFlow", "GreetingFlowConfig"] diff --git a/examples/flow/01_custom_flow/flows/greeting_flow/flow.py b/examples/flow/01_custom_flow/flows/greeting_flow/flow.py deleted file mode 100644 index d2e82d2..0000000 --- a/examples/flow/01_custom_flow/flows/greeting_flow/flow.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Simple greeting flow demonstrating custom flow creation.""" - -from typing import Any, Dict - -from quantmind.config.flows import BaseFlowConfig -from quantmind.config.llm import LLMConfig -from quantmind.config.registry import register_flow_config -from quantmind.flow.base import BaseFlow - - -@register_flow_config("greeting") -class GreetingFlowConfig(BaseFlowConfig): - """Configuration for greeting flow.""" - - def model_post_init(self, __context: Any) -> None: - """Initialize default configuration.""" - # First load prompt templates from path if specified - super().model_post_init(__context) - - if not self.llm_blocks: - self.llm_blocks = { - "greeter": LLMConfig( - model="gpt-4o-mini", temperature=0.7, max_tokens=500 - ) - } - - -class GreetingFlow(BaseFlow): - """A simple custom flow that greets users and provides suggestions.""" - - def run(self, user_input: Dict[str, Any]) -> Dict[str, str]: - """Execute the greeting flow. - - Args: - user_input: Dictionary containing 'user_name' and 'topic' - - Returns: - Dictionary with greeting and suggestions - """ - user_name = user_input.get("user_name", "there") - topic = user_input.get("topic", "learning") - - # Step 1: Generate greeting - greeter_llm = self._llm_blocks["greeter"] - - greeting_prompt = self._render_prompt( - "greeting_template", user_name=user_name, topic=topic - ) - - greeting = greeter_llm.generate_text(greeting_prompt) - - # Step 2: Generate follow-up suggestions - follow_up_prompt = self._render_prompt( - "follow_up_template", user_name=user_name, topic=topic - ) - - suggestions = greeter_llm.generate_text(follow_up_prompt) - - return { - "greeting": greeting or "Hello! Welcome to our system!", - "suggestions": suggestions or "Keep exploring and learning!", - } diff --git a/examples/flow/01_custom_flow/flows/greeting_flow/prompts.yaml b/examples/flow/01_custom_flow/flows/greeting_flow/prompts.yaml deleted file mode 100644 index 4507a28..0000000 --- a/examples/flow/01_custom_flow/flows/greeting_flow/prompts.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Prompt templates for GreetingFlow -templates: - greeting_template: | - You are a friendly assistant. Greet the user and ask about their interest in {{ topic }}. - - User: {{ user_name }} - Topic: {{ topic }} - - Generate a personalized greeting. - - follow_up_template: | - Based on the user's name "{{ user_name }}" and their interest in {{ topic }}, - suggest 3 relevant next steps they might take. - - Be encouraging and specific. diff --git a/examples/flow/01_custom_flow/pipeline.py b/examples/flow/01_custom_flow/pipeline.py deleted file mode 100644 index c3f9e93..0000000 --- a/examples/flow/01_custom_flow/pipeline.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -"""Demo pipeline showing how to create and use a custom flow.""" - -from pathlib import Path - -# Import the custom flow -from flows.greeting_flow import GreetingFlow -from quantmind.config.settings import load_config - - -def main(): - """Run the custom flow demo.""" - print("=== Custom Flow Demo: Greeting Flow ===") - print() - - # Step 1: Load configuration from YAML using QuantMind settings - config_path = Path(__file__).parent / "config.yaml" - settings = load_config(config_path) - - # Step 2: Get the greeting flow configuration and convert to GreetingFlowConfig - config = settings.flows["greeting_flow"] - - print(f"📁 Flow name: {config.name}") - print(f"📁 Templates path: {config.prompt_templates_path}") - - print(f"✓ Loaded {len(config.prompt_templates)} prompt templates") - print(f"✓ Configured {len(config.llm_blocks)} LLM blocks") - print() - - # Step 3: Initialize the flow - try: - flow = GreetingFlow(config) - print("✓ Flow initialized successfully") - except Exception as e: - print(f"✗ Flow initialization failed: {e}") - print("This is expected without proper API configuration") - return - - # Step 4: Run the flow with sample data - user_inputs = [ - {"user_name": "Alice", "topic": "quantitative finance"}, - {"user_name": "Bob", "topic": "machine learning"}, - {"user_name": "Carol", "topic": "data science"}, - ] - - for user_input in user_inputs: - print(f"\n--- Processing: {user_input} ---") - try: - result = flow.run(user_input) - print("Greeting:", result.get("greeting", "N/A")) - print("Suggestions:", result.get("suggestions", "N/A")) - except Exception as e: - print(f"✗ Flow execution failed: {e}") - print("This is expected without proper API keys") - - print("\n=== Demo Complete ===") - print("\nKey takeaways from this example:") - print("• Simple flow configuration with dataclass") - print("• YAML-based prompt templates") - print("• Direct LLM block access (no wrapper methods)") - print("• Python-based orchestration logic") - print("• Easy to customize and extend") - - -if __name__ == "__main__": - main() diff --git a/examples/flow/02_summary_flow/.env.example b/examples/flow/02_summary_flow/.env.example deleted file mode 100644 index 52dba40..0000000 --- a/examples/flow/02_summary_flow/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Example environment file for QuantMind flows -# Copy this file to .env and fill in your actual API keys - -# OpenAI API Key (for GPT models) -OPENAI_API_KEY=sk-your-openai-api-key-here - -# Anthropic API Key (for Claude models) -ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key-here - -# Google API Key (for Gemini models) -GOOGLE_API_KEY=your-google-api-key-here - -# DeepSeek API Key (for DeepSeek models) -DEEPSEEK_API_KEY=your-deepseek-api-key-here - -# Custom environment variables (you can define your own) -MY_CUSTOM_LLM_KEY=your-custom-key-here diff --git a/examples/flow/02_summary_flow/README.md b/examples/flow/02_summary_flow/README.md deleted file mode 100644 index e3c933c..0000000 --- a/examples/flow/02_summary_flow/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Summary Flow Example - -This example demonstrates the built-in SummaryFlow with custom chunking strategy and automatic API key management. - -## Structure - -```bash -examples/flow/02_summary_flow/ -├── flows/ -│ └── summary_flow/ -│ ├── __init__.py # Module exports -│ ├── prompts.yaml # Prompt templates -│ └── flow.py # Mock LLM implementation (for testing) -├── config.yaml # Flow configuration -├── mock_data.py # Sample financial papers -├── pipeline.py # Entry point -├── .env.example # Example .env file -└── README.md # This file -``` - -## Key Components - -### 1. Built-in SummaryFlow - -- Two-stage summarization: cheap model for chunks → powerful model for combination -- Flexible chunking strategies: size-based, custom, or disabled -- Cost-optimized mixture mode - -### 2. Custom Chunking Strategy - -- Demonstrates `ChunkingStrategy.BY_CUSTOM` -- User-defined chunking function (paragraph-based in demo) -- Runtime configuration update - -### 3. Automatic API Key Management - -- Environment variable resolution from `.env` file -- Smart provider inference (OpenAI models use `OPENAI_API_KEY`) -- Secure configuration without hardcoded keys - -## Easy Import Structure - -With the new `__init__.py` file, you can import the demo flow cleanly: - -```python -# Import from flow directory -from flows.summary_flow import DemoSummaryFlow - -# Use in pipelines -flow = DemoSummaryFlow(config) -``` - -## Running the Demo - -```bash -cd examples/flow/02_summary_flow -# Prepare the api-key .env file -cp ../../../.env.example .env -# Edit .env with your actual API keys -python pipeline.py -``` - -## Features Demonstrated - -1. **Custom Chunking**: Paragraph-based instead of size-based splitting -2. **Two-stage Processing**: Cost-effective bulk processing + high-quality synthesis -3. **API Key Resolution**: Automatic environment variable discovery -4. **Template Separation**: YAML-based prompts for easy editing -5. **Type-safe Configuration**: Proper `SummaryFlowConfig` loading - -## Configuration - -```yaml -# config.yaml -flows: - summary_demo: - type: "summary" # Uses built-in SummaryFlow - config: - name: "summary_flow" - prompt_templates_path: "flows/summary_flow/prompts.yaml" - use_chunking: true - chunk_size: 1000 -``` - -## Benefits Demonstrated - -1. **Smart Resource Usage**: Cheap model for bulk work, powerful model for synthesis -2. **Flexible Chunking**: Easy to implement custom splitting strategies -3. **Secure Configuration**: Environment variable-based API key management -4. **Simple Integration**: Built-in flow with minimal configuration -5. **Template Flexibility**: External YAML prompt templates - -This showcases the framework's principle: **powerful built-in flows with flexible customization options**. diff --git a/examples/flow/02_summary_flow/config.yaml b/examples/flow/02_summary_flow/config.yaml deleted file mode 100644 index 7521362..0000000 --- a/examples/flow/02_summary_flow/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Configuration for summary flow demo -flows: - summary_demo: - type: "summary" - config: - name: "summary_flow" - prompt_templates_path: "flows/summary_flow/prompts.yaml" - use_chunking: true - chunk_size: 1000 # Smaller chunks for demo diff --git a/examples/flow/02_summary_flow/flows/summary_flow/__init__.py b/examples/flow/02_summary_flow/flows/summary_flow/__init__.py deleted file mode 100644 index 436ca0f..0000000 --- a/examples/flow/02_summary_flow/flows/summary_flow/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Summary flow module.""" - -from .flow import DemoSummaryFlow - -__all__ = ["DemoSummaryFlow"] diff --git a/examples/flow/02_summary_flow/flows/summary_flow/flow.py b/examples/flow/02_summary_flow/flows/summary_flow/flow.py deleted file mode 100644 index ddbc18f..0000000 --- a/examples/flow/02_summary_flow/flows/summary_flow/flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Summary flow with mock LLM for demonstration.""" - -from typing import List -from quantmind.config.flows import SummaryFlowConfig -from quantmind.flow.summary_flow import SummaryFlow - - -class MockLLMBlock: - """Mock LLM block for demonstration purposes.""" - - def __init__(self, model_name: str): - self.model_name = model_name - - def generate_text(self, prompt: str) -> str: - """Generate mock responses based on model type.""" - if ( - "cheap_summarizer" in self.model_name - or "gpt-4o-mini" in self.model_name - ): - # Mock cheap model response - shorter, more focused - if "methodology" in prompt.lower(): - return "This section discusses ML algorithms for financial prediction using historical data." - elif "results" in prompt.lower(): - return ( - "The model achieved 67% accuracy with Sharpe ratio of 1.8." - ) - else: - return "Key findings: Machine learning shows promise for financial applications." - - elif ( - "powerful_combiner" in self.model_name - or "gpt-4o" in self.model_name - ): - # Mock powerful model response - comprehensive combination - if "combine" in prompt.lower() or "summaries" in prompt.lower(): - return ( - "## Comprehensive Summary\n\n" - "This research demonstrates the successful application of machine learning " - "techniques in quantitative finance. The study employs various ML algorithms " - "including random forests and neural networks to predict market movements.\n\n" - "**Key Achievements:**\n" - "- Achieved 67% directional prediction accuracy\n" - "- Sharpe ratio improvement to 1.8 vs 1.2 baseline\n" - "- Demonstrated superiority over traditional statistical models\n\n" - "**Methodology:** The approach combines technical indicators with fundamental " - "analysis metrics, processed through ensemble learning methods.\n\n" - "**Implications:** These results suggest significant potential for ML-driven " - "trading strategies in institutional finance applications." - ) - else: - return ( - "This comprehensive analysis of machine learning applications in finance " - "demonstrates significant improvements over traditional approaches, with " - "practical implications for algorithmic trading and risk management." - ) - - return "Mock response generated successfully." - - -class DemoSummaryFlow(SummaryFlow): - """Summary flow with mock LLM blocks for demonstration.""" - - def _initialize_llm_blocks(self, llm_configs): - """Override to use mock LLM blocks.""" - llm_blocks = {} - for identifier, llm_config in llm_configs.items(): - # Create mock LLM blocks instead of real ones - mock_llm = MockLLMBlock(f"{identifier}_{llm_config.model}") - llm_blocks[identifier] = mock_llm - print( - f"✓ Initialized mock LLM '{identifier}' with model: {llm_config.model}" - ) - - return llm_blocks diff --git a/examples/flow/02_summary_flow/flows/summary_flow/prompts.yaml b/examples/flow/02_summary_flow/flows/summary_flow/prompts.yaml deleted file mode 100644 index 4459b2d..0000000 --- a/examples/flow/02_summary_flow/flows/summary_flow/prompts.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Prompt templates for SummaryFlow -templates: - summarize_chunk_template: | - You are a financial research expert. Summarize the following content chunk - focusing on key insights, methodology, and findings. Keep it concise but comprehensive. - - Content: - {{ chunk_text }} - - Summary: - - combine_summaries_template: | - You are a financial research expert. Combine the following chunk summaries - into a coherent, comprehensive final summary. Eliminate redundancy and - create a well-structured overview. - - Chunk Summaries: - {{ summaries }} - - Final Summary: diff --git a/examples/flow/02_summary_flow/mock_data.py b/examples/flow/02_summary_flow/mock_data.py deleted file mode 100644 index cdcccdd..0000000 --- a/examples/flow/02_summary_flow/mock_data.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Mock financial research papers for demonstration.""" - -from quantmind.models.content import KnowledgeItem - - -def get_sample_papers(): - """Get sample financial research papers for testing.""" - # Large paper that will be chunked - large_paper = KnowledgeItem( - title="Machine Learning Applications in Algorithmic Trading: A Comprehensive Study", - abstract=( - "This paper presents a comprehensive analysis of machine learning techniques " - "applied to algorithmic trading strategies. We evaluate various ML models " - "including random forests, gradient boosting, and deep neural networks on " - "high-frequency trading data from major equity markets." - ), - content=( - "Introduction:\n" - "The financial markets have undergone significant transformation with the advent " - "of algorithmic trading. Machine learning techniques offer unprecedented " - "opportunities to identify complex patterns in market data that traditional " - "statistical methods might miss. This study focuses on the practical application " - "of ML algorithms in creating profitable trading strategies.\n\n" - "Literature Review:\n" - "Previous research in this domain has shown mixed results. Early studies by " - "Johnson et al. (2018) demonstrated modest improvements using linear models, " - "while more recent work by Chen and Liu (2021) achieved breakthrough results " - "with deep learning architectures. However, most studies lack comprehensive " - "evaluation across different market conditions and asset classes.\n\n" - "Methodology:\n" - "Our experimental setup involves training multiple ML models on 5 years of " - "high-frequency data from S&P 500 constituents. We employ a rolling window " - "approach with 252-day training periods and 63-day testing periods. Feature " - "engineering includes technical indicators (RSI, MACD, Bollinger Bands), " - "fundamental metrics (P/E ratios, earnings growth), and market microstructure " - "variables (bid-ask spreads, order book depth).\n\n" - "Model Architecture:\n" - "We implement three primary model types: (1) Random Forest with 1000 trees " - "and maximum depth of 10, (2) Gradient Boosting with learning rate 0.01 and " - "500 estimators, and (3) LSTM neural network with 128 hidden units and dropout " - "regularization. Each model is optimized using grid search cross-validation.\n\n" - "Results:\n" - "The experimental results demonstrate significant improvements over baseline " - "strategies. The ensemble approach combining all three models achieved 67% " - "directional accuracy compared to 52% for random walk baseline. Risk-adjusted " - "returns measured by Sharpe ratio improved from 1.2 to 1.8. Maximum drawdown " - "was reduced from 15% to 8%, indicating better risk management.\n\n" - "Statistical Analysis:\n" - "We conducted rigorous statistical testing to ensure result significance. " - "Bootstrap confidence intervals show 95% confidence that true accuracy lies " - "between 64-70%. Paired t-tests confirm statistical significance (p < 0.001) " - "for performance improvements. Out-of-sample testing on 2023 data validates " - "model robustness across different market regimes.\n\n" - "Risk Management:\n" - "Implementation includes comprehensive risk controls: position sizing based on " - "volatility targeting, correlation limits to prevent over-concentration, and " - "dynamic stop-loss levels adjusted for market volatility. These controls " - "contributed significantly to the improved risk-adjusted performance.\n\n" - "Conclusion:\n" - "This study demonstrates that machine learning techniques can significantly " - "enhance algorithmic trading performance when properly implemented with " - "appropriate risk controls. The key success factors include comprehensive " - "feature engineering, ensemble modeling approaches, and robust validation " - "methodologies. Future research should explore alternative data sources " - "and investigate model interpretability for regulatory compliance." - ), - authors=["Dr. Sarah Chen", "Prof. Michael Rodriguez", "Dr. Alex Kim"], - categories=["q-fin.TR", "q-fin.ST", "cs.AI"], - tags=[ - "machine learning", - "algorithmic trading", - "quantitative finance", - "risk management", - ], - source="demo_data", - ) - - # Small paper that won't be chunked - small_paper = KnowledgeItem( - title="High-Frequency Trading Impact on Market Liquidity", - abstract=( - "This study examines the impact of high-frequency trading on market liquidity " - "using transaction-level data from NYSE and NASDAQ." - ), - content=( - "This research analyzes high-frequency trading effects on market quality metrics. " - "Using millisecond-level data, we find that HFT improves bid-ask spreads by " - "12% on average but increases volatility during stress periods. The net effect " - "on market welfare depends on trading volume and market conditions." - ), - authors=["Dr. Jennifer Wang"], - categories=["q-fin.TR"], - tags=[ - "high-frequency trading", - "market liquidity", - "market microstructure", - ], - source="demo_data", - ) - - return [large_paper, small_paper] - - -def get_custom_chunking_example(): - """Get example with custom chunking strategy.""" - - def paragraph_chunker(text: str): - """Custom chunker that splits by paragraphs.""" - paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] - return paragraphs - - paper = KnowledgeItem( - title="Custom Chunking Strategy Demo", - abstract="Demonstrates paragraph-based chunking instead of size-based.", - content=( - "First paragraph discusses the introduction to the topic.\n\n" - "Second paragraph covers the methodology used in the research.\n\n" - "Third paragraph presents the main results and findings.\n\n" - "Fourth paragraph concludes with implications and future work." - ), - authors=["Demo Author"], - categories=["demo"], - tags=["custom chunking"], - source="demo_data", - ) - - return paper, paragraph_chunker diff --git a/examples/flow/02_summary_flow/pipeline.py b/examples/flow/02_summary_flow/pipeline.py deleted file mode 100644 index e7eb638..0000000 --- a/examples/flow/02_summary_flow/pipeline.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Demo pipeline for SummaryFlow with custom chunking strategy.""" - -from pathlib import Path - -from mock_data import get_custom_chunking_example - -from quantmind.config.settings import load_config -from quantmind.flow.summary_flow import SummaryFlow - - -def main(): - """Run the summary flow demo with custom chunking.""" - print("=== Summary Flow Demo: Custom Chunking ===") - print() - - # Step 1: Load configuration from YAML using QuantMind settings - config_path = Path(__file__).parent / "config.yaml" - settings = load_config(config_path) - - # Step 2: Get the summary flow configuration - config = settings.flows["summary_demo"] - - print(f"📁 Flow name: {config.name}") - print(f"📁 Templates path: {config.prompt_templates_path}") - print(f"✓ Loaded {len(config.prompt_templates)} prompt templates") - print(f"✓ Configured {len(config.llm_blocks)} LLM blocks") - print() - - # Step 3: Get sample paper with custom chunking strategy - paper, custom_chunker = get_custom_chunking_example() - - print(f"📄 Paper: '{paper.title}' ({len(paper.content)} chars)") - print(f"🧩 Custom chunker: {custom_chunker.__name__}") - print() - - # Step 4: Update config to use custom chunking - from quantmind.config.flows import ChunkingStrategy - - config.chunk_strategy = ChunkingStrategy.BY_CUSTOM - config.chunk_custom_strategy = custom_chunker - - print(f"⚙️ Chunking: {config.use_chunking}") - print(f"⚙️ Strategy: {config.chunk_strategy.value}") - print(f"⚙️ Custom function: {config.chunk_custom_strategy.__name__}") - print() - - # Step 5: Initialize and run the flow - try: - flow = SummaryFlow(config) - print("✓ Flow initialized successfully") - - # Show LLM blocks configuration - print("\n🤖 LLM Configuration:") - for name, llm_config in config.llm_blocks.items(): - key_status = "✓ Found" if llm_config.api_key else "✗ Missing" - print(f" • {name}: {llm_config.model} ({key_status})") - print() - - # Run the flow - print("🚀 Running summary flow...") - result = flow.run(paper) - - print(f"✓ Generated summary ({len(result)} chars)") - print("\n" + "=" * 50) - print("📝 SUMMARY:") - print("=" * 50) - print(result) - print("=" * 50) - - except Exception as e: - print(f"✗ Flow execution failed: {e}") - print("💡 Make sure you have set up API keys in .env file") - - print("\n=== Demo Complete ===") - print("\nKey features demonstrated:") - print("• Custom chunking strategy implementation") - print("• Two-stage summarization (cheap + powerful LLMs)") - print("• Automatic API key resolution from environment") - print("• YAML-based prompt templates") - print("• Type-safe configuration loading") - - -if __name__ == "__main__": - main() diff --git a/examples/flow/03_podcast_flow/README.md b/examples/flow/03_podcast_flow/README.md deleted file mode 100644 index 9e55f42..0000000 --- a/examples/flow/03_podcast_flow/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Podcast Flow Examples - -This directory contains examples demonstrating how to use the `PodcastFlow` class to generate podcast scripts from summary input, following the same structure as other custom flows in this project. - -## Structure - -``` -03_podcast_flow/ -├── README.md # This documentation file -├── pipeline.py # Main demo pipeline -├── config.yaml # Configuration file -└── flows/ - └── podcast_flow/ - ├── __init__.py # Package initialization - ├── flow.py # Main flow implementation - └── prompts.yaml # Prompt templates -``` - -## Quick Start - -```bash -# Run the demo pipeline -python pipeline.py - -# Or import and use in your code -from flows.podcast_flow.flow import PodcastFlow -``` - -## Configuration - -The flow is configured through `config.yaml`: - -```yaml -flows: - podcast_flow: - type: "podcast" - config: - name: "podcast_flow" - prompt_templates_path: "flows/podcast_flow/prompts.yaml" - llm_blocks: - main_generator: - model: "gpt-4o-mini" - temperature: 0.5 - max_tokens: 1000 -``` - -## Usage - -```python -from flows.podcast_flow.flow import PodcastFlow -from quantmind.config.settings import load_config - -# Load configuration -settings = load_config("config.yaml") -config = settings.flows["podcast_flow"] - -# Create and run flow -flow = PodcastFlow(config) -script = flow.run( - summary="Your summary text here..." -) -``` - -## Features - -- **Flexible Input**: Accepts summary text and optional intro/outro hints -- **LLM Integration**: Uses configured LLM blocks for content generation -- **Template System**: Supports customizable prompt templates via YAML -- **Structured Output**: Returns organized script sections (intro, main, outro) -- **Fallback Support**: Gracefully handles missing LLM blocks or templates - -## Output Format - -The flow returns a dictionary with: -```python -{ - "main": "Generated main content...", -} -``` - -## Prompt Templates - -The flow uses three main prompt templates: - -1. **intro_prompt**: Generates engaging podcast introductions -2. **main_prompt**: Converts summaries into conversational podcast content -3. **outro_prompt**: Creates effective podcast conclusions - -## Environment Setup - -Set your API keys as environment variables: -```bash -export OPENAI_API_KEY="your-openai-api-key" -``` - -## Examples - -The `pipeline.py` file demonstrates: -- Loading configuration from YAML -- Initializing the flow -- Running with multiple sample inputs -- Error handling for missing API keys - -## Key Takeaways - -- **Consistent Structure**: Follows the same pattern as other custom flows -- **YAML Configuration**: Easy to modify without changing code -- **Template System**: Flexible prompt management -- **Error Handling**: Graceful fallbacks for missing resources -- **Extensible**: Easy to customize for different podcast styles diff --git a/examples/flow/03_podcast_flow/config.yaml b/examples/flow/03_podcast_flow/config.yaml deleted file mode 100644 index 09f19f6..0000000 --- a/examples/flow/03_podcast_flow/config.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Configuration for podcast flow example -flows: - podcast_flow: - type: "podcast" - config: - name: "podcast_flow" - prompt_templates_path: "flows/podcast_flow/prompts.yaml" - llm_blocks: - main_generator: - model: "gpt-4o-mini" - temperature: 0.5 - max_tokens: 1000 - api_key: ${OPENAI_API_KEY} - num_speakers: 2 - speaker_languages: "en-us" - summary_hint: "" diff --git a/examples/flow/03_podcast_flow/flows/podcast_flow/__init__.py b/examples/flow/03_podcast_flow/flows/podcast_flow/__init__.py deleted file mode 100644 index a3d80b7..0000000 --- a/examples/flow/03_podcast_flow/flows/podcast_flow/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Podcast Flow Package. - -This package contains the implementation of the PodcastFlow -for generating podcast scripts from summary input. -""" - -from .flow import CustomizedPodcastFlow - -__all__ = ["CustomizedPodcastFlow"] diff --git a/examples/flow/03_podcast_flow/flows/podcast_flow/flow.py b/examples/flow/03_podcast_flow/flows/podcast_flow/flow.py deleted file mode 100644 index bfca8d2..0000000 --- a/examples/flow/03_podcast_flow/flows/podcast_flow/flow.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Podcast flow with mock LLM for demonstration.""" - -from typing import Dict, Any -from venv import create -from quantmind.config.flows import PodcastFlowConfig -from quantmind.flow.podcast_flow import PodcastFlow -from quantmind.llm.block import LLMBlock, create_llm_block - - -class CustomizedPodcastFlowConfig(PodcastFlowConfig): - """Configuration for the PodcastFlow with LLM blocks.""" - - num_speakers: int = 2 - speaker_languages: str = "en-us" - summary_hint: str = "This is a sample summary hint for the podcast." - - -class CustomizedPodcastFlow(PodcastFlow): - """Podcast flow with mock LLM blocks for demonstration.""" - - def _initialize_llm_blocks(self, llm_configs): - """Override to use mock LLM blocks.""" - llm_blocks = {} - for identifier, llm_config in llm_configs.items(): - # Create mock LLM blocks instead of real ones - llm_block = create_llm_block(llm_config) - llm_blocks[identifier] = llm_block - print( - f"✓ Initialized mock LLM '{identifier}' with model: {llm_config.model}" - ) - - return llm_blocks diff --git a/examples/flow/03_podcast_flow/flows/podcast_flow/prompts.yaml b/examples/flow/03_podcast_flow/flows/podcast_flow/prompts.yaml deleted file mode 100644 index 3dd4949..0000000 --- a/examples/flow/03_podcast_flow/flows/podcast_flow/prompts.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Prompt templates for PodcastFlow -templates: - main_prompt: | - You are a professional podcast script writer. Convert into engaging podcast content based on this summary. - - Summary: {{ summary_hint }} - - Generate the main podcast content: diff --git a/examples/flow/03_podcast_flow/pipeline.py b/examples/flow/03_podcast_flow/pipeline.py deleted file mode 100644 index 974fb2f..0000000 --- a/examples/flow/03_podcast_flow/pipeline.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Demo pipeline showing how to create and use a podcast flow.""" - -import json -from pathlib import Path - -# Import the custom flow -from flows.podcast_flow.flow import PodcastFlow - -from quantmind.config.settings import load_config - - -def main(): - """Run the podcast flow demo.""" - print("=== Custom Flow Demo: Podcast Flow ===") - print() - - # Step 1: Load configuration from YAML using QuantMind settings - config_path = Path(__file__).parent / "config.yaml" - settings = load_config(config_path) - - # Step 2: Get the podcast flow configuration and convert to PodcastFlowConfig - config = settings.flows["podcast_flow"] - - print(f"📁 Flow name: {config.name}") - print(f"📁 Templates path: {config.prompt_templates_path}") - - print(f"✓ Loaded {len(config.prompt_templates)} prompt templates") - print(f"✓ Configured {len(config.llm_blocks)} LLM blocks") - print() - - # Step 3: Initialize the flow - try: - flow = PodcastFlow(config) - print("✓ Flow initialized successfully") - except Exception as e: - print(f"✗ Flow initialization failed: {e}") - print("This is expected without proper API configuration") - return - - # Step 4: Run the flow with sample data - sample_inputs = [ - { - "summary": "Artificial Intelligence is transforming healthcare in unprecedented ways. Machine learning algorithms can now diagnose diseases with accuracy rates exceeding human doctors. AI-powered imaging systems detect early-stage cancers that might be missed by traditional methods.", - } - ] - - for i, input_data in enumerate(sample_inputs, 1): - print(f"\n--- Processing Podcast {i}: {input_data['summary']} ---") - try: - result = flow.run(summary=input_data["summary"]) - assert isinstance( - result, dict - ), "Flow output should be a dictionary" - with open(f"podcast_script_{i}.json", "w") as f: - json.dump(result, f, indent=4) - print(f"✓ Podcast script saved to podcast_script_{i}.json") - except Exception as e: - print(f"✗ Flow execution failed: {e}") - print("This is expected without proper API keys") - - print("\n=== Demo Complete ===") - print("\nKey takeaways from this example:") - print("• Podcast flow configuration with dataclass") - print("• YAML-based prompt templates for main") - print("• Multiple LLM blocks for different content types") - print("• Python-based orchestration logic") - print("• Easy to customize and extend for different podcast styles") - - -if __name__ == "__main__": - main() diff --git a/examples/flow/README.md b/examples/flow/README.md deleted file mode 100644 index 056cf32..0000000 --- a/examples/flow/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Flow Examples - -This directory contains examples demonstrating the new QuantMind flow architecture. - -## Examples Overview - -### [01_custom_flow](./01_custom_flow/) - Simple Custom Flow - -Learn how to create a custom flow from scratch: - -- Basic flow configuration with Pydantic models -- YAML-based prompt templates -- Python-based orchestration logic -- Direct LLM block access - -**Key Learning**: How to implement custom business logic using the new architecture. - -### [02_summary_flow](./02_summary_flow/) - Built-in Summary Flow Demo - -Explore the built-in SummaryFlow with various configurations: - -- Chunking strategies (size-based, custom, disabled) -- Mixture mode (cheap + powerful LLMs) -- Mock LLM implementation for testing -- Real financial research paper examples - -**Key Learning**: How to leverage and configure built-in flows for optimal cost/quality trade-offs. - -### [03_podcast_flow](./03_podcast_flow/) - Built-in Podcast Flow Demo - -Explore the built-in PodcastFlow with various configurations: - -- Mixture mode (intro, main, outro) -- Mock LLM implementation for testing -- Real podcast script examples in various domains - -**Key Learning**: How to leverage and configure built-in flows for optimal script in specific domain. - -## Architecture Principles Demonstrated - -Both examples showcase the core principles of the new flow architecture: - -1. **Resource-Based Configuration**: Config defines resources (LLM blocks, templates), not orchestration logic -2. **Code-Based Orchestration**: Business logic implemented in Python for maximum flexibility -3. **Direct Access**: No unnecessary wrapper methods, just direct access to resources -4. **Template Separation**: Prompts in YAML files for easy editing and maintenance -5. **Type Safety**: Pydantic-based configuration instead of complex schemas -6. **Easy Imports**: Each flow directory has `__init__.py` for clean imports (e.g., `from flows.greeting_flow import GreetingFlow`) - -## Quick Start - -Choose the example that matches your needs: - -- **Want to create a custom flow?** → Start with `01_custom_flow` -- **Want to use built-in flows?** → Start with `02_summary_flow` - -Each example is self-contained and includes: - -- Complete working code -- Mock implementations (no API keys required) -- Comprehensive documentation -- Clear explanations of benefits - -## Running Examples - -Each example can be run independently: - -```bash -# Custom flow example -cd 01_custom_flow -# Prepare the api-key .env file -cp .env.example .env -python pipeline.py -``` - -```bash -# Summary flow example -cd 02_summary_flow -# Prepare the api-key .env file -cp .env.example .env -python pipeline.py -``` - -```bash -# Podcast flow example -cd 03_podcast_flow -# Prepare the api-key .env file -cp .env.example .env -python pipeline.py -``` - -Both examples work without API keys by using mock LLM implementations that demonstrate the flow logic and architecture benefits. diff --git a/examples/llm/embedding_block_example.py b/examples/llm/embedding_block_example.py deleted file mode 100644 index ec477c7..0000000 --- a/examples/llm/embedding_block_example.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Example usage of EmbeddingBlock for different embedding providers.""" - -import os -from typing import List - -from quantmind.config import EmbeddingConfig -from quantmind.llm import EmbeddingBlock, create_embedding_block -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - - -def example_openai_embeddings(): - """Example using OpenAI embeddings.""" - print("\n=== OpenAI Embeddings Example ===") - - # Configuration for OpenAI embeddings - config = EmbeddingConfig( - model="text-embedding-ada-002", - api_key=os.getenv("OPENAI_API_KEY"), - timeout=30, - encoding_format="float", - ) - - # Create embedding block - embedding_block = create_embedding_block(config) - - # Test connection - if embedding_block.test_connection(): - print("✅ OpenAI connection successful") - else: - print("❌ OpenAI connection failed") - return - - # Generate single embedding - text = "This is a sample text for embedding generation." - embedding = embedding_block.generate_embedding(text) - - if embedding: - print(f"✅ Generated embedding with {len(embedding)} dimensions") - print(f" First 5 values: {embedding[:5]}") - - # Generate multiple embeddings - texts = [ - "First sample text for embedding.", - "Second sample text with different content.", - "Third sample text for batch processing.", - ] - - embeddings = embedding_block.generate_embeddings(texts) - - if embeddings: - print(f"✅ Generated {len(embeddings)} embeddings") - for i, emb in enumerate(embeddings): - print(f" Text {i + 1}: {len(emb)} dimensions") - - # Get embedding information - info = embedding_block.get_info() - print(f"📊 Model info: {info['model']}") - print(f"📊 Provider: {info['provider']}") - - -def example_azure_embeddings(): - """Example using Azure OpenAI embeddings.""" - print("\n=== Azure OpenAI Embeddings Example ===") - - # Configuration for Azure OpenAI embeddings - config = EmbeddingConfig( - model="text-embedding-ada-002", - api_key=os.getenv("AZURE_API_KEY"), - api_base=os.getenv("AZURE_API_BASE"), - api_version=os.getenv("AZURE_API_VERSION", "2023-05-15"), - api_type="azure", - timeout=30, - encoding_format="float", - ) - - # Create embedding block - embedding_block = create_embedding_block(config) - - # Test connection - if embedding_block.test_connection(): - print("✅ Azure OpenAI connection successful") - else: - print("❌ Azure OpenAI connection failed") - return - - # Generate single embedding - text = "This is a sample text for Azure OpenAI embedding generation." - embedding = embedding_block.generate_embedding(text) - - if embedding: - print(f"✅ Generated embedding with {len(embedding)} dimensions") - print(f" First 5 values: {embedding[:5]}") - - # Generate multiple embeddings - texts = [ - "First sample text for Azure embedding.", - "Second sample text with different content.", - "Third sample text for batch processing.", - ] - - embeddings = embedding_block.generate_embeddings(texts) - - if embeddings: - print(f"✅ Generated {len(embeddings)} embeddings") - for i, emb in enumerate(embeddings): - print(f" Text {i + 1}: {len(emb)} dimensions") - - # Get embedding information - info = embedding_block.get_info() - print(f"📊 Model info: {info['model']}") - print(f"📊 Provider: {info['provider']}") - - -def example_configuration_variants(): - """Example showing different configuration variants.""" - print("\n=== Configuration Variants Example ===") - - # Base configuration - base_config = EmbeddingConfig( - model="text-embedding-ada-002", - api_key=os.getenv("OPENAI_API_KEY"), - encoding_format="float", - ) - - # Create variants with different parameters - fast_config = base_config.create_variant(timeout=10, retry_attempts=1) - - conservative_config = base_config.create_variant( - timeout=120, retry_attempts=5, retry_delay=2.0 - ) - - print(f"Base config timeout: {base_config.timeout}s") - print(f"Fast config timeout: {fast_config.timeout}s") - print(f"Conservative config timeout: {conservative_config.timeout}s") - - # Test with temporary configuration - embedding_block = create_embedding_block(base_config) - - with embedding_block.temporary_config(timeout=5): - print("Using temporary configuration with 5s timeout") - # Any embedding operations here will use the temporary config - embedding = embedding_block.generate_embedding("Test with temp config") - if embedding: - print("✅ Temporary configuration worked") - - -def example_error_handling(): - """Example showing error handling and fallbacks.""" - print("\n=== Error Handling Example ===") - - # Try with invalid API key - config = EmbeddingConfig( - model="text-embedding-ada-002", - api_key="invalid_key", - timeout=5, - ) - - embedding_block = create_embedding_block(config) - - # This should fail gracefully - embedding = embedding_block.generate_embedding("Test text") - if embedding is None: - print("✅ Gracefully handled invalid API key") - - # Try with non-existent model - config = EmbeddingConfig( - model="non-existent-model", - timeout=5, - ) - - try: - embedding_block = create_embedding_block(config) - print("❌ Should have failed with non-existent model") - except Exception as e: - print(f"✅ Gracefully handled non-existent model: {e}") - - -def main(): - """Run all embedding examples.""" - print("🚀 EmbeddingBlock Examples") - print("=" * 50) - - # Run examples based on available API keys - if os.getenv("OPENAI_API_KEY"): - example_openai_embeddings() - else: - print("\n⚠️ Skipping OpenAI examples - OPENAI_API_KEY not set") - - if os.getenv("AZURE_API_KEY") and os.getenv("AZURE_API_BASE"): - example_azure_embeddings() - else: - print( - "\n⚠️ Skipping Azure example - AZURE_API_KEY or AZURE_API_BASE not set" - ) - - example_configuration_variants() - example_error_handling() - - print("\n✅ All examples completed!") - - -if __name__ == "__main__": - main() diff --git a/examples/llm/llm_block_example.py b/examples/llm/llm_block_example.py deleted file mode 100644 index 0268e37..0000000 --- a/examples/llm/llm_block_example.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Example demonstrating the LLMBlock architecture.""" - -import os - -from dotenv import load_dotenv - -from quantmind.config import LLMConfig -from quantmind.llm import create_llm_block - -load_dotenv() - - -def example_basic_llm_block(): - """Example 1: Basic LLMBlock usage.""" - print("=== Example 1: Basic LLMBlock Usage ===") - - # Create LLM configuration - config = LLMConfig( - model="deepseek/deepseek-chat", # LiteLLM format - temperature=0.0, - max_tokens=1000, - api_key=os.getenv("DEEPSEEK_API_KEY"), - ) - - # Create LLMBlock - llm_block = create_llm_block(config) - - # Test connection - if llm_block.test_connection(): - print("✅ LLMBlock connection successful!") - - # Generate text - response = llm_block.generate_text("What is machine learning?") - print(f"Response: {response[:100]}...") - - # Get block info - print(f"Block info: {llm_block.get_info()}") - else: - print("❌ LLMBlock connection failed") - - -def example_advanced_features(): - """Example 5: Advanced features and configuration.""" - print("\n=== Example 5: Advanced Features ===") - - # Advanced configuration - config = LLMConfig( - model="gpt-4o", - temperature=0.7, - max_tokens=2000, - top_p=0.9, - timeout=120, - retry_attempts=5, - retry_delay=2.0, - system_prompt="You are a quantitative finance expert.", - custom_instructions="Always provide practical examples and code snippets.", - extra_params={"frequency_penalty": 0.1, "presence_penalty": 0.1}, - ) - - print(f"Advanced config: {config.model_dump()}") - - # Create LLMBlock - llm_block = create_llm_block(config) - - # Using context manager for temporary changes - with llm_block.temporary_config(temperature=0.0, max_tokens=500): - print("Inside temporary config context") - print(f"Current block info: {llm_block.get_info()}") - - print("Outside temporary config context") - print(f"Current block info: {llm_block.get_info()}") - - -if __name__ == "__main__": - print("🚀 QuantMind LLMBlock Architecture Examples") - print("=" * 50) - - # Run examples - example_basic_llm_block() - example_advanced_features() - - print("\n✅ All examples completed!") diff --git a/examples/memory/basic_memory_usage.py b/examples/memory/basic_memory_usage.py deleted file mode 100644 index 5642bf5..0000000 --- a/examples/memory/basic_memory_usage.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Minimal example showing how to build a memory timeline.""" - -from quantmind.brain.memory import Memory -from quantmind.models.memory import ActionStep, TaskStep, ToolCall -from quantmind.models.messages import ChatMessage, MessageRole -from quantmind.utils.monitoring import Timing, TokenUsage - - -def main(): - """Main function.""" - memory = Memory("You are a quantitative research assistant.") - - memory.steps.append(TaskStep(task="Gather the latest market sentiment.")) - - memory.steps.append( - ActionStep( - step_number=1, - timing=Timing(start_time=0.0, end_time=0.5), - model_input_messages=[ - ChatMessage( - role=MessageRole.USER, - content=[ - {"type": "text", "text": "Any updates on bond markets?"} - ], - ) - ], - tool_calls=[ - ToolCall( - name="fetch_sentiment", - arguments={"asset": "treasury", "lookback": "1d"}, - id="call-1", - ) - ], - model_output="Sentiment looks neutral across regions.", - observations="Tool returned neutral scores.", - token_usage=TokenUsage(input_tokens=12, output_tokens=9), - ) - ) - - print("Succinct steps:") - for step in memory.get_succinct_steps(): - print(step) - - print("\nMessages replay:") - messages = [] - messages.extend(memory.system_prompt.to_messages()) - for step in memory.steps: - messages.extend(step.to_messages()) - - # Define colors for different message roles - ROLE_COLORS = { - MessageRole.SYSTEM: "\033[35m", # Magenta - MessageRole.USER: "\033[32m", # Green - MessageRole.ASSISTANT: "\033[36m", # Cyan - MessageRole.TOOL_CALL: "\033[33m", # Yellow - MessageRole.TOOL_RESPONSE: "\033[34m", # Blue - } - RESET = "\033[0m" - - for message in messages: - color = ROLE_COLORS.get(message.role, "") - print(f"{color}[{message.role.value}]{RESET} {message.content}") - - -if __name__ == "__main__": - main() diff --git a/examples/parser/llama_parser_example.ipynb b/examples/parser/llama_parser_example.ipynb deleted file mode 100644 index 0caf201..0000000 --- a/examples/parser/llama_parser_example.ipynb +++ /dev/null @@ -1,115 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import getpass\n", - "from pathlib import Path\n", - "\n", - "from autoscholar.parser.llama_parser import LlamaParser, ParsingMode, ResultType" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "llama_parse_api = getpass.getpass(\"Enter your LlamaParse API key: \")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Started parsing the file under job_id 8842778c-ce0f-4c05-a295-51bdb80b9e1d\n" - ] - } - ], - "source": [ - "import nest_asyncio\n", - "\n", - "nest_asyncio.apply()\n", - "\n", - "test_pdf_path = Path(\"test-pdf.pdf\")\n", - "parser = LlamaParser(\n", - " api_key=llama_parse_api,\n", - " parsing_mode=ParsingMode.BALANCED,\n", - " result_type=ResultType.MD,\n", - ")\n", - "\n", - "result = parser.parse(test_pdf_path)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "('# DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open '\n", - " 'Language Models\\n'\n", - " '\\n'\n", - " '# Zhihong Shao')\n", - "['img_p0_1.png']\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from io import BytesIO\n", - "from pprint import pprint\n", - "\n", - "from PIL import Image\n", - "\n", - "pprint(result.get_markdown_documents()[0].text[:100])\n", - "\n", - "pprint(result.get_image_names())\n", - "\n", - "Image.open(BytesIO(result.get_image_data(\"img_p0_1.png\")))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.16" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/examples/parser/simple_llama_parser.py b/examples/parser/simple_llama_parser.py deleted file mode 100644 index c7be9ca..0000000 --- a/examples/parser/simple_llama_parser.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Simple LlamaParser usage example. - -This example shows basic usage of the LlamaParser for parsing -individual PDF files or URLs using the new Pydantic configuration system. -""" - -import logging -import os -from pathlib import Path - -from colorama import Fore, Style - -from quantmind.config.parsers import LlamaParserConfig, ParsingMode, ResultType -from quantmind.models.paper import Paper -from quantmind.parsers.llama_parser import LlamaParser - -# Set logging level to DEBUG -logging.basicConfig(level=logging.DEBUG) - - -def demo_file_parsing(): - """Demonstrate parsing a local PDF file.""" - print("=== File Parsing Demo ===\n") - - # Create parser with Pydantic configuration - config = LlamaParserConfig( - api_key=os.getenv("LLAMA_CLOUD_API_KEY") or "demo_key", - result_type=ResultType.MD, - parsing_mode=ParsingMode.FAST, - max_file_size_mb=25, - ) - parser = LlamaParser(config) - - # Example PDF path (would need to exist for real usage) - pdf_path = "./examples/parser/test-pdf.pdf" - - print(f"Parser configuration:") - print( - f"- Result type: {parser.llama_config.result_type if isinstance(parser.llama_config.result_type, str) else parser.llama_config.result_type.value}" - ) - print( - f"- Parsing mode: {parser.llama_config.parsing_mode if isinstance(parser.llama_config.parsing_mode, str) else parser.llama_config.parsing_mode.value}" - ) - print(f"- Max file size: {parser.llama_config.max_file_size_mb}MB") - print() - - if Path(pdf_path).exists(): - print(f"Parsing file: {pdf_path}") - try: - # Parse the file - content = parser.extract_from_file(pdf_path) - print( - Fore.GREEN - + f"Successfully parsed {len(content)} characters" - + Style.RESET_ALL - ) - print( - Fore.GREEN - + f"Content preview: {content[:200]}..." - + Style.RESET_ALL - ) - - except Exception as e: - print(Fore.RED + f"Parsing error: {e}" + Style.RESET_ALL) - else: - print(Fore.RED + f"Demo PDF not found at: {pdf_path}" + Style.RESET_ALL) - print( - Fore.YELLOW - + "In real usage, provide path to an existing PDF file." - + Style.RESET_ALL - ) - - print() - - -def demo_url_parsing(): - """Demonstrate parsing a PDF from URL.""" - print("=== URL Parsing Demo ===\n") - - config = LlamaParserConfig( - api_key=os.getenv("LLAMA_CLOUD_API_KEY") or "demo_key", - result_type=ResultType.TXT, - parsing_mode=ParsingMode.BALANCED, - ) - parser = LlamaParser(config) - - # Example PDF URL (ArXiv paper) - pdf_url = "https://arxiv.org/pdf/2301.00001.pdf" - - print(f"Parsing URL: {pdf_url}") - print("Note: This requires valid LLAMA_CLOUD_API_KEY") - - try: - # Parse from URL - content = parser.extract_from_url(pdf_url) - print( - Fore.GREEN - + f"Successfully parsed {len(content)} characters" - + Style.RESET_ALL - ) - print( - Fore.GREEN - + f"Content preview: {content[:200]}..." - + Style.RESET_ALL - ) - - except Exception as e: - print( - Fore.RED + f"Expected error without API key: {e}" + Style.RESET_ALL - ) - - print() - - -def demo_paper_parsing(): - """Demonstrate parsing a Paper object.""" - print("=== Paper Object Parsing Demo ===\n") - - # Create a Paper object - paper = Paper( - paper_id="2301.00001", - title="Example Financial Research Paper", - authors=["Author One", "Author Two"], - abstract="This paper demonstrates quantitative methods...", - pdf_url="https://arxiv.org/pdf/2301.00001.pdf", - categories=["q-fin.ST"], - ) - - # Create parser with advanced configuration - config = LlamaParserConfig( - api_key=os.getenv("LLAMA_CLOUD_API_KEY") or "demo_key", - result_type=ResultType.MD, - parsing_mode=ParsingMode.PREMIUM, - system_prompt=( - "Extract key findings, methodology, and quantitative " - "results from this financial research paper." - ), - system_prompt_append=( - "Focus on statistical methods and financial metrics." - ), - ) - parser = LlamaParser(config) - - print(f"Paper: {paper.title}") - print(f"Authors: {', '.join(paper.authors)}") - print(f"PDF URL: {paper.pdf_url}") - print() - - print("Parser configuration:") - info = parser.get_parser_info() - for key, value in info.items(): - if key != "config": # Skip detailed config - print(f"- {key}: {value}") - print() - - try: - # Parse the paper - enriched_paper = parser.parse_paper(paper) - - if enriched_paper.content: - print(Fore.GREEN + f"Parsing successful!" + Style.RESET_ALL) - print( - Fore.GREEN - + f"Content length: {len(enriched_paper.content)} characters" - + Style.RESET_ALL - ) - print( - Fore.GREEN - + f"Parser info: {enriched_paper.meta_info.get('parser_info', {})}" - + Style.RESET_ALL - ) - else: - print( - Fore.RED - + "No content extracted (expected without API key)" - + Style.RESET_ALL - ) - - except Exception as e: - print( - Fore.RED + f"Expected error without API key: {e}" + Style.RESET_ALL - ) - - print() - - -def demo_configuration_options(): - """Demonstrate different configuration options.""" - print("=== Configuration Options Demo ===\n") - - # Get API key once for all configurations - api_key = os.getenv("LLAMA_CLOUD_API_KEY") or "demo_key" - - configurations = [ - { - "name": "Fast Text Extraction", - "config": LlamaParserConfig( - api_key=api_key, - result_type=ResultType.TXT, - parsing_mode=ParsingMode.FAST, - max_file_size_mb=50, - ), - }, - { - "name": "Balanced Markdown", - "config": LlamaParserConfig( - api_key=api_key, - result_type=ResultType.MD, - parsing_mode=ParsingMode.BALANCED, - max_file_size_mb=25, - ), - }, - { - "name": "Premium with Custom Prompts", - "config": LlamaParserConfig( - api_key=api_key, - result_type=ResultType.MD, - parsing_mode=ParsingMode.PREMIUM, - max_file_size_mb=15, - system_prompt="Extract financial data and analysis", - system_prompt_append="Include all numerical results", - ), - }, - ] - - for config_info in configurations: - print(f"Configuration: {config_info['name']}") - try: - parser = LlamaParser(config_info["config"]) - info = parser.get_parser_info() - print(f"- Result type: {info['result_type']}") - print(f"- Parsing mode: {info['parsing_mode']}") - print(f"- Max file size: {info['max_file_size_mb']}MB") - if info.get("system_prompt"): - print(f"- Custom prompt: Yes") - print() - - except Exception as e: - print(Fore.RED + f"Configuration error: {e}" + Style.RESET_ALL) - print() - - -def main(): - """Run all demonstration examples.""" - print("QuantMind LlamaParser: Simple Usage Examples") - print("=" * 45) - print() - - # Check for API key - try: - api_key = os.getenv("LLAMA_CLOUD_API_KEY") - if api_key: - print("✅ LLAMA_CLOUD_API_KEY found - examples will use real API") - else: - print("⚠️ LLAMA_CLOUD_API_KEY not set - running in demo mode") - print( - " 💡 Tip: Create a .env file with LLAMA_CLOUD_API_KEY=your_key" - ) - print(" Or set as environment variable for full functionality") - except Exception as e: - print(f"⚠️ Error loading API key: {e}") - print(" Running in demo mode") - print() - - # Run demonstrations - demo_configuration_options() - demo_file_parsing() - demo_url_parsing() - demo_paper_parsing() - - print("=" * 45) - print(Fore.GREEN + "Examples completed!" + Style.RESET_ALL) - print("\nNext steps:") - print( - Fore.YELLOW + "1. 🔑 Set up API key for real parsing:" + Style.RESET_ALL - ) - print( - Fore.YELLOW - + " • Create .env file: LLAMA_CLOUD_API_KEY=your_actual_key" - + Style.RESET_ALL - ) - print( - Fore.YELLOW - + " • Or set environment variable: export LLAMA_CLOUD_API_KEY=your_key" - + Style.RESET_ALL - ) - print(Fore.YELLOW + "2. 📄 Try with your own PDF files" + Style.RESET_ALL) - print( - Fore.YELLOW - + "3. ⚙️ Experiment with different parsing modes" - + Style.RESET_ALL - ) - print( - Fore.YELLOW - + "4. 🔄 See arxiv_llama_pipeline.py for full workflow integration" - + Style.RESET_ALL - ) - print( - Fore.YELLOW - + "\n💡 Create a .env file with: LLAMA_CLOUD_API_KEY=your_api_key_here" - + Style.RESET_ALL - ) - - -if __name__ == "__main__": - main() diff --git a/examples/parser/test-pdf.pdf b/examples/parser/test-pdf.pdf deleted file mode 100644 index 91d8281..0000000 Binary files a/examples/parser/test-pdf.pdf and /dev/null differ diff --git a/examples/pipeline/arxiv_llama_pipeline.py b/examples/pipeline/arxiv_llama_pipeline.py deleted file mode 100644 index c3648e0..0000000 --- a/examples/pipeline/arxiv_llama_pipeline.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Simple demo: ArXiv source + LlamaParser integration. - -Direct usage example: -1. Get "Attention Is All You Need" paper from ArXiv -2. Parse with LlamaParser (fast mode) -3. Show parsed content - -No complex pipeline - just basic integration demo. -""" - -from colorama import Fore, Style, init - -from quantmind.config.parsers import LlamaParserConfig, ParsingMode, ResultType -from quantmind.parsers.llama_parser import LlamaParser -from quantmind.sources.arxiv_source import ArxivSource - -# Initialize colorama -init(autoreset=True) - - -def main(): - """Direct ArXiv + LlamaParser demo.""" - print( - f"{Fore.CYAN}{Style.BRIGHT}=== ArXiv + LlamaParser Simple Demo ==={Style.RESET_ALL}\n" - ) - - try: - # 1. Get paper from ArXiv - print( - f"{Fore.YELLOW}📚 Step 1: Searching ArXiv for 'Attention Is All You Need'..." - ) - arxiv_source = ArxivSource( - config={ - "max_results": 1, - "download_pdfs": True, - } - ) - - papers = arxiv_source.search( - query='ti:"Attention Is All You Need"', max_results=1 - ) - - if not papers: - print(f"{Fore.RED}❌ Paper not found") - return - - paper = papers[0] - print(f"{Fore.GREEN}✅ Found: {Style.BRIGHT}{paper.title}") - print(f"{Fore.BLUE} 👥 Authors: {', '.join(paper.authors[:3])}") - print(f"{Fore.BLUE} 🔗 PDF URL: {paper.pdf_url}") - print() - - # 2. Parse with LlamaParser (fast, cheap mode) - print( - f"{Fore.YELLOW}🔧 Step 2: Parsing with LlamaParser (fast mode)..." - ) - llama_config = LlamaParserConfig( - result_type=ResultType.TXT, # Cheapest - parsing_mode=ParsingMode.FAST, # Fastest - max_file_size_mb=25, - ) - - llama_parser = LlamaParser(llama_config) - parsed_paper = llama_parser.parse_paper(paper) - - # 3. Show results - print(f"{Fore.GREEN}✅ Parsing completed!") - print( - f"{Fore.MAGENTA} 📊 Content length: {Style.BRIGHT}{len(parsed_paper.content):,} characters" - ) - print( - f"{Fore.MAGENTA} ⚙️ Parser info: {parsed_paper.meta_info.get('parser_info', {})}" - ) - print() - print(f"{Fore.CYAN}📖 First 500 characters of parsed content:") - print(f"{Fore.CYAN}{'-' * 50}") - print(f"{Style.DIM}{parsed_paper.content[:500]}...") - - except Exception as e: - print(f"{Fore.RED}❌ Error: {e}") - print( - f"{Fore.YELLOW}💡 Note: Requires LLAMA_CLOUD_API_KEY environment variable" - ) - - -if __name__ == "__main__": - main() diff --git a/examples/pipeline/arxiv_storage_pipeline.py b/examples/pipeline/arxiv_storage_pipeline.py deleted file mode 100644 index 046d89e..0000000 --- a/examples/pipeline/arxiv_storage_pipeline.py +++ /dev/null @@ -1,39 +0,0 @@ -from quantmind.config import ArxivSourceConfig, LocalStorageConfig -from quantmind.sources import ArxivSource -from quantmind.storage import LocalStorage - - -def arxiv_storage_pipeline(): - """Example of how to use the LocalStorage to store papers from Arxiv.""" - arxiv_source_config = ArxivSourceConfig(max_results=3, timeout=5) - local_storage_config = LocalStorageConfig(storage_dir="./data") - - arxiv_source = ArxivSource(config=arxiv_source_config) - local_storage = LocalStorage(config=local_storage_config) - - papers = arxiv_source.search("LLM in Quant Trading") - local_storage.process_knowledges(papers) - - # You can also use the basic operation to deliver the same result - # for paper in papers: - # pdf_url = paper.pdf_url - # if pdf_url: - # import requests - # response = requests.get(pdf_url) - # pdf_path = local_storage.store_raw_file( - # file_id=paper.get_primary_id(), - # content=response.content, - # file_extension=".pdf", - # ) - - # Check if the pdf is stored - for paper in papers: - pdf_path = local_storage.get_raw_file(paper.get_primary_id()) - if pdf_path: - print(f"PDF found at {pdf_path}") - else: - print(f"PDF not found for {paper.title}") - - -if __name__ == "__main__": - arxiv_storage_pipeline() diff --git a/examples/pipeline/quant_paper/.env.example b/examples/pipeline/quant_paper/.env.example deleted file mode 100644 index 21e5ffd..0000000 --- a/examples/pipeline/quant_paper/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# Environment variables for Quant Paper Agent -# Copy this to .env and fill in your API keys - -# Google Gemini API Key (for Gemini 2.5 Pro & Flash) -GOOGLE_API_KEY=your_google_api_key_here - -# LlamaCloud API Key (for PDF parsing) -LLAMA_CLOUD_API_KEY=your_llamacloud_api_key_here - -# Optional: Custom base URLs (if using proxies or different endpoints) -# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com -# LLAMA_BASE_URL=https://api.llamaindex.ai diff --git a/examples/pipeline/quant_paper/README.md b/examples/pipeline/quant_paper/README.md deleted file mode 100644 index 5bd46df..0000000 --- a/examples/pipeline/quant_paper/README.md +++ /dev/null @@ -1,221 +0,0 @@ -# Quant Paper Agent - Production Example - -A production-level pipeline demonstrating intelligent quantitative finance paper processing using Google's Gemini 2.5 Pro & Flash models. - -## Features - -🔍 **Smart Paper Discovery**: Searches ArXiv for cutting-edge quantitative finance research -📄 **Advanced PDF Parsing**: Uses LlamaParser for high-quality content extraction -🧠 **Intelligent Summarization**: Gemini 2.5 Flash for chunks, Pro for synthesis -❓ **Thoughtful QA Generation**: Creates engaging questions for web display -💾 **Rich Metadata Storage**: Uses `meta_info` pattern for extensible data storage - -## Architecture - -This example demonstrates the **meta_info best practice** for storing Flow-generated content: - -```python -# QA Flow stores results in meta_info -paper.meta_info["qa_data"] = { - "questions": [{"id": "q_1", "question": "...", "category": "methodology"}], - "generated_at": "2024-01-15T10:30:00", - "model_used": "gemini-2.5-pro", - "question_count": 7 -} - -# Summary Flow stores results in meta_info -paper.meta_info["summary"] = "Comprehensive summary text..." -``` - -## Setup - -### 1. Environment Variables - -```bash -export GOOGLE_API_KEY="your-gemini-api-key" -export LLAMA_CLOUD_API_KEY="your-llamaparse-key" -``` - -### 2. Install Dependencies - -```bash -# From project root -uv pip install -e . -``` - -### 3. Run Pipeline - -```bash -cd examples/pipeline/quant_paper -python pipeline.py -``` - -## Configuration - -### Gemini Models Used - -- **Gemini 2.5 Flash**: Cost-effective for chunk summarization and question refinement -- **Gemini 2.5 Pro**: High-quality for final summary synthesis and question generation - -### Flow Configuration - -```yaml -flows: - summary_flow: - llm_blocks: - cheap_summarizer: - provider: "google" - model: "gemini-2.5-flash" - powerful_combiner: - provider: "google" - model: "gemini-2.5-pro" - - qa_flow: - max_questions: 7 - question_depth: "deep" - focus_areas: ["methodology", "findings", "implications", "applications", "limitations"] -``` - -## Pipeline Workflow - -1. **Paper Discovery**: Search ArXiv with finance-focused queries -2. **Content Extraction**: Download PDF and parse with LlamaParser -3. **Intelligent Summarization**: Two-stage process with Gemini models -4. **QA Generation**: Create thoughtful questions for engagement -5. **Metadata Storage**: Store all results in `paper.meta_info` -6. **Persistence**: Save enriched paper with full metadata - -## Output Structure - -### Stored Paper Data - -```json -{ - "title": "Paper Title", - "abstract": "Paper abstract...", - "content": "Full extracted text...", - "meta_info": { - "summary": "Intelligent summary...", - "summary_generated_at": "2024-01-15T10:30:00", - "qa_data": { - "questions": [ - { - "id": "q_1", - "question": "How does the proposed methodology handle market volatility?", - "category": "methodology", - "difficulty": "intermediate" - } - ], - "generated_at": "2024-01-15T10:31:00", - "model_used": "gemini-2.5-pro", - "question_count": 7, - "focus_areas": ["methodology", "findings", "implications"] - }, - "storage_path": "/path/to/stored/paper.json" - } -} -``` - -### Generated Questions - -Questions are categorized for web display: - -- **Category**: methodology, findings, implications, applications, limitations -- **Difficulty**: basic, intermediate, advanced -- **Structure**: Ready for web rendering - -## Best Practices Demonstrated - -### 1. Meta_info Pattern ✅ - -Store Flow outputs in `paper.meta_info` rather than extending Paper model: - -```python -# ✅ Good: Flexible storage -paper.meta_info["qa_data"] = qa_results -paper.meta_info["custom_analysis"] = analysis_results - -# ❌ Avoid: Rigid model extension -class Paper: - qa_questions: List[Dict] = None # Breaks separation of concerns -``` - -### 2. Production Configuration ✅ - -- Use environment variables for API keys -- Separate models for different tasks (Flash vs Pro) -- Reasonable token limits and temperatures -- Error handling and retries - -### 3. Comprehensive Logging ✅ - -- Structured logging throughout pipeline -- Progress indicators for user experience -- Error context for debugging - -## Accessing Stored Data - -```python -from quantmind.models.paper import Paper - -# Load stored paper -paper = Paper.load_from_file("data/knowledges/arxiv_id.json") - -# Access generated content -summary = paper.meta_info.get("summary") -qa_data = paper.meta_info.get("qa_data", {}) -questions = qa_data.get("questions", []) - -# Display questions on web -for q in questions: - print(f"Q: {q['question']}") - print(f"Category: {q['category']} | Level: {q['difficulty']}") -``` - -## Extending the Pipeline - -### Adding New Flows - -1. Create flow in `flows/new_flow/` -2. Register configuration with `@register_flow_config` -3. Store results in `paper.meta_info["new_flow_data"]` -4. Update pipeline to run new flow - -### Custom Processing - -```python -# Add custom analysis -custom_flow = CustomAnalysisFlow(config) -analysis_result = custom_flow.run(paper) - -# Store in meta_info -paper.meta_info["custom_analysis"] = analysis_result -paper.meta_info["custom_analysis_timestamp"] = datetime.now().isoformat() -``` - -## Performance Notes - -- **Gemini 2.5 Flash**: ~2-3 seconds per chunk summary -- **Gemini 2.5 Pro**: ~5-8 seconds for final synthesis -- **Total Pipeline**: ~2-5 minutes per paper (depending on PDF size) -- **Cost Optimization**: Flash for repetitive tasks, Pro for complex reasoning - -## Troubleshooting - -### Common Issues - -1. **Missing API Keys**: Set `GOOGLE_API_KEY` and `LLAMA_CLOUD_API_KEY` -2. **PDF Parse Failures**: Check LlamaParser quota and PDF quality -3. **Model Errors**: Verify Gemini API access and quota -4. **Storage Issues**: Ensure write permissions in `./data` directory - -### Debug Mode - -```python -import logging -logging.getLogger("quantmind").setLevel(logging.DEBUG) -``` - ---- - -This example showcases production-ready quantitative finance paper processing with modern AI models and extensible metadata storage patterns. diff --git a/examples/pipeline/quant_paper/config.yaml b/examples/pipeline/quant_paper/config.yaml deleted file mode 100644 index bcaf55f..0000000 --- a/examples/pipeline/quant_paper/config.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# Configuration for Production Quant Paper Agent Example -# Uses Gemini 2.5 Pro & Flash for production-level performance - -# Source configuration -source: - type: "arxiv" - config: - max_results: 1 - retry_delay: 1.0 - -# Parser configuration -parser: - type: "llama" - config: - api_key: ${LLAMA_CLOUD_API_KEY} - parsing_instructions: "Extract comprehensive quantitative finance content including methodologies, mathematical models, empirical findings, and practical implications." - -# Storage configuration -storage: - type: "local" - config: - base_path: "./data" - auto_create_dirs: true - enable_indexing: true - -# Flow configurations -flows: - # Summary flow using Gemini models - summary_flow: - type: "summary" - config: - name: "summary_flow" - prompt_templates_path: "flows/summary_flow/prompts.yaml" - use_chunking: true - chunk_size: 4000 - chunk_strategy: "by_size" - llm_blocks: - cheap_summarizer: - model: "gemini/gemini-2.5-flash" - temperature: 0.3 - max_tokens: 8192 - api_key: ${GOOGLE_API_KEY} - powerful_combiner: - model: "gemini/gemini-2.5-pro" - temperature: 0.2 - max_tokens: 8192 - api_key: ${GOOGLE_API_KEY} - - # QA flow using Gemini models for production - qa_flow: - type: "qa" - config: - name: "qa_flow" - prompt_templates_path: "flows/qa_flow/prompts.yaml" - max_questions: 7 - question_depth: "deep" - focus_areas: ["methodology", "findings", "implications", "applications", "limitations"] - llm_blocks: - question_generator: - model: "gemini/gemini-2.5-pro" - temperature: 0.8 - max_tokens: 8192 - api_key: ${GOOGLE_API_KEY} - question_refiner: - model: "gemini/gemini-2.5-pro" - temperature: 0.3 - max_tokens: 8192 - api_key: ${GOOGLE_API_KEY} diff --git a/examples/pipeline/quant_paper/flows/qa_flow/__init__.py b/examples/pipeline/quant_paper/flows/qa_flow/__init__.py deleted file mode 100644 index d118451..0000000 --- a/examples/pipeline/quant_paper/flows/qa_flow/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""QA Flow components for generating thoughtful questions from research papers.""" - -from .flow import QAFlow, QAFlowConfig - -__all__ = ["QAFlow", "QAFlowConfig"] diff --git a/examples/pipeline/quant_paper/flows/qa_flow/flow.py b/examples/pipeline/quant_paper/flows/qa_flow/flow.py deleted file mode 100644 index f08fc9f..0000000 --- a/examples/pipeline/quant_paper/flows/qa_flow/flow.py +++ /dev/null @@ -1,245 +0,0 @@ -"""QA Flow for generating thoughtful questions from research papers.""" - -from datetime import datetime -from typing import Dict, List - -from quantmind.config.flows import BaseFlowConfig -from quantmind.config.registry import register_flow_config -from quantmind.flow.base import BaseFlow -from quantmind.models.content import KnowledgeItem -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - -# JSON schema for structured question output -QUESTION_SCHEMA = { - "type": "object", - "properties": { - "question": {"type": "string", "description": "The refined question"}, - "category": { - "type": "string", - "enum": [ - "methodology", - "findings", - "implications", - "applications", - "theory", - ], - "description": "The category of the question", - }, - "difficulty": { - "type": "string", - "enum": ["basic", "intermediate", "advanced"], - "description": "The difficulty level of the question", - }, - }, - "required": ["question", "category", "difficulty"], - "additionalProperties": False, -} - -# JSON schema for multiple questions generation -QUESTIONS_LIST_SCHEMA = { - "type": "object", - "properties": { - "questions": { - "type": "array", - "items": { - "type": "string", - "description": "A thoughtful question about the research paper", - }, - "description": "List of generated questions", - } - }, - "required": ["questions"], - "additionalProperties": False, -} - - -@register_flow_config("qa") -class QAFlowConfig(BaseFlowConfig): - """Configuration for QA flow.""" - - max_questions: int = 5 - question_depth: str = "deep" # "basic", "intermediate", "deep" - focus_areas: List[str] = ["methodology", "findings", "implications"] - - -class QAFlow(BaseFlow): - """A flow that generates thoughtful QA questions from research papers. - - This flow analyzes the paper content and generates questions that promote - deep thinking about the research, suitable for web display and engagement. - """ - - def run(self, document: KnowledgeItem) -> List[Dict[str, str]]: - """Execute the QA generation flow. - - Args: - document: KnowledgeItem (typically a Paper) to generate questions for - - Returns: - List of dictionaries containing question data - """ - logger.info(f"Starting QA flow for: {document.title}") - - content = document.content or "" - if not content: - logger.warning("No content available for QA generation") - return [] - - # Step 1: Generate initial questions - initial_questions = self._generate_initial_questions(document) - if not initial_questions: - logger.error("Failed to generate initial questions") - return [] - - # Step 2: Refine and categorize questions - refined_questions = self._refine_questions(initial_questions, document) - - # Step 3: Store in meta_info following best practice - qa_metadata = { - "questions": refined_questions, - "generated_at": datetime.now().isoformat(), - "model_used": self.config.llm_blocks["question_generator"].model, - "question_count": len(refined_questions), - "focus_areas": self.config.focus_areas, - "depth_level": self.config.question_depth, - } - - document.meta_info["qa_data"] = qa_metadata - logger.info( - f"Generated {len(refined_questions)} questions for: {document.title}" - ) - - return refined_questions - - def _generate_initial_questions(self, document: KnowledgeItem) -> List[str]: - """Generate initial set of questions based on paper content.""" - generator_llm = self._llm_blocks["question_generator"] - - # Prepare content for analysis (limit size for API efficiency) - analysis_content = self._prepare_content_for_analysis(document) - - prompt = self._render_prompt( - "generate_questions_template", - title=document.title, - abstract=document.abstract or "", - content=analysis_content, - max_questions=self.config.max_questions, - depth=self.config.question_depth, - focus_areas=", ".join(self.config.focus_areas), - ) - - try: - # Use structured output generation for initial questions - response_format = { - "type": "json_object", - "response_schema": QUESTIONS_LIST_SCHEMA, - } - structured_response = generator_llm.generate_structured_output( - prompt, response_format=response_format - ) - - if structured_response and isinstance(structured_response, dict): - questions = structured_response.get("questions", []) - # Limit to max_questions and ensure all are strings ending with '?' - questions = [ - q - for q in questions - if isinstance(q, str) and q.strip().endswith("?") - ] - questions = questions[: self.config.max_questions] - logger.debug(f"Generated {len(questions)} initial questions") - return questions - else: - logger.warning( - "No structured response received for initial questions" - ) - return [] - - except Exception as e: - logger.error(f"Error generating questions: {e}") - return [] - - def _refine_questions( - self, questions: List[str], document: KnowledgeItem - ) -> List[Dict[str, str]]: - """Refine questions and add metadata using structured output.""" - refiner_llm = self._llm_blocks["question_refiner"] - refined_questions = [] - - for i, question in enumerate(questions): - try: - refine_prompt = self._render_prompt( - "refine_question_template", - question=question, - title=document.title, - abstract=document.abstract or "", - ) - - # Use structured output generation with schema - response_format = { - "type": "json_object", - "response_schema": QUESTION_SCHEMA, - } - structured_response = refiner_llm.generate_structured_output( - refine_prompt, response_format=response_format - ) - - if structured_response and isinstance( - structured_response, dict - ): - # Validate required fields and add ID - question_data = { - "id": f"q_{i + 1}", - "question": structured_response.get( - "question", question - ), - "category": structured_response.get( - "category", "general" - ), - "difficulty": structured_response.get( - "difficulty", "intermediate" - ), - } - refined_questions.append(question_data) - else: - # Fallback to original question - refined_questions.append( - { - "id": f"q_{i + 1}", - "question": question, - "category": "general", - "difficulty": "intermediate", - } - ) - - except Exception as e: - logger.warning(f"Error refining question {i + 1}: {e}") - # Fallback to original question - refined_questions.append( - { - "id": f"q_{i + 1}", - "question": question, - "category": "general", - "difficulty": "intermediate", - } - ) - - return refined_questions - - def _prepare_content_for_analysis(self, document: KnowledgeItem) -> str: - """Prepare content for analysis, limiting size for API efficiency.""" - content = document.content or "" - - # Limit content to avoid API token limits - max_chars = 8000 # Approximately 2000 tokens - if len(content) > max_chars: - # Take first portion + last portion to capture intro and conclusion - first_half = content[: max_chars // 2] - last_half = content[-max_chars // 2 :] - content = ( - first_half + "\n\n[... content truncated ...]\n\n" + last_half - ) - - return content diff --git a/examples/pipeline/quant_paper/flows/qa_flow/prompts.yaml b/examples/pipeline/quant_paper/flows/qa_flow/prompts.yaml deleted file mode 100644 index 7fa9839..0000000 --- a/examples/pipeline/quant_paper/flows/qa_flow/prompts.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# Prompt templates for QAFlow -templates: - generate_questions_template: | - You are a financial research expert and educational content creator. Your task is to generate {{ max_questions }} thoughtful, engaging questions based on the following research paper. - - Paper Title: {{ title }} - - Abstract: - {{ abstract }} - - Content (partial): - {{ content }} - - Generate {{ max_questions }} questions at {{ depth }} level focusing on: {{ focus_areas }} - - Requirements: - - Questions should promote critical thinking and deeper understanding - - Focus on methodology, implications, and practical applications - - Suitable for web display and reader engagement - - Each question should end with a question mark - - Please respond with valid JSON in the following format: - { - "questions": [ - "First thoughtful question about the research?", - "Second insightful question about the methodology?", - "Third question about practical implications?" - ] - } - - Ensure the response is valid JSON without any additional text or formatting. - - refine_question_template: | - You are an educational content specialist. Refine the following question to make it more engaging and categorize it appropriately. - - Original Question: {{ question }} - Paper Title: {{ title }} - Abstract: {{ abstract }} - - Make the question clear, specific, and thought-provoking while maintaining its educational value. - - Please respond with valid JSON in the following format: - { - "question": "refined version of the question", - "category": "methodology|findings|implications|applications|theory", - "difficulty": "basic|intermediate|advanced" - } - - Ensure the response is valid JSON without any additional text or formatting. diff --git a/examples/pipeline/quant_paper/flows/summary_flow/prompts.yaml b/examples/pipeline/quant_paper/flows/summary_flow/prompts.yaml deleted file mode 100644 index f516d2c..0000000 --- a/examples/pipeline/quant_paper/flows/summary_flow/prompts.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Prompt templates for SummaryFlow with enhanced quantitative finance focus -templates: - summarize_chunk_template: | - You are an expert quantitative finance researcher and analyst. Summarize the following research content with a focus on: - - 1. Key quantitative methodologies and mathematical models - 2. Empirical findings and statistical results - 3. Practical applications in finance - 4. Novel contributions to the field - 5. Data sources and experimental design - - Maintain technical accuracy while ensuring clarity. Include specific metrics, formulas, or results when mentioned. - - Content: - {{ chunk_text }} - - Comprehensive Summary: - - combine_summaries_template: | - You are an expert quantitative finance researcher. Synthesize the following chunk summaries into a coherent, comprehensive final summary. - - Requirements: - - Create a well-structured overview that flows logically - - Preserve technical details and quantitative insights - - Eliminate redundancy while maintaining completeness - - Highlight the most significant contributions and findings - - Organize into clear sections (methodology, findings, implications) - - Chunk Summaries: - {{ summaries }} - - Final Comprehensive Summary: diff --git a/examples/pipeline/quant_paper/pipeline.py b/examples/pipeline/quant_paper/pipeline.py deleted file mode 100644 index 4bc483c..0000000 --- a/examples/pipeline/quant_paper/pipeline.py +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env python3 -"""Production-level Quant Paper Agent Pipeline using Gemini 2.5 Pro & Flash. - -This pipeline demonstrates: -1. Real-world paper extraction from ArXiv -2. Advanced PDF parsing with LlamaParser -3. Intelligent summarization using Gemini models -4. QA generation for web engagement -5. Storage with rich metadata using meta_info approach -""" - -import sys -from pathlib import Path -from typing import Optional - -from flows.qa_flow.flow import QAFlow - -from quantmind.config.settings import load_config -from quantmind.flow.summary_flow import SummaryFlow -from quantmind.models.paper import Paper -from quantmind.parsers.llama_parser import LlamaParser -from quantmind.sources.arxiv_source import ArxivSource -from quantmind.storage.local_storage import LocalStorage -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - - -# Color codes for better terminal output -class Colors: - """ANSI color codes for terminal output.""" - - HEADER = "\033[95m" - OKBLUE = "\033[94m" - OKCYAN = "\033[96m" - OKGREEN = "\033[92m" - WARNING = "\033[93m" - FAIL = "\033[91m" - ENDC = "\033[0m" - BOLD = "\033[1m" - UNDERLINE = "\033[4m" - - # Emoji-like symbols - ROCKET = "🚀" - CHECK = "✓" - CROSS = "❌" - ARROW = "📡" - DOWNLOAD = "📥" - SEARCH = "🔍" - WRITE = "📝" - QUESTION = "❓" - SAVE = "💾" - CELEBRATE = "🎉" - INFO = "📄" - GEAR = "🔧" - STOP = "⏹️" - BULB = "💡" - - -def cprint(text: str, color: str = Colors.ENDC, bold: bool = False) -> None: - """Print colored text to terminal.""" - prefix = Colors.BOLD if bold else "" - print(f"{prefix}{color}{text}{Colors.ENDC}") - - -def print_header(text: str) -> None: - """Print a styled header.""" - cprint(f"\n{Colors.ROCKET} {text}", Colors.HEADER, bold=True) - - -def print_success(text: str) -> None: - """Print a success message.""" - cprint(f"{Colors.CHECK} {text}", Colors.OKGREEN) - - -def print_error(text: str) -> None: - """Print an error message.""" - cprint(f"{Colors.CROSS} {text}", Colors.FAIL, bold=True) - - -def print_info(text: str) -> None: - """Print an info message.""" - cprint(f"{Colors.INFO} {text}", Colors.OKBLUE) - - -def print_step(icon: str, text: str) -> None: - """Print a pipeline step.""" - cprint(f"\n{icon} {text}", Colors.OKCYAN, bold=True) - - -def get_unique_paper( - arxiv_source: ArxivSource, local_storage: LocalStorage, retry_count: int = 3 -) -> Optional[Paper]: - """Get a unique paper from ArXiv focusing on quantitative finance.""" - search_queries = [ - "Large Language Models quantitative finance", - "machine learning portfolio optimization", - "deep learning algorithmic trading", - "neural networks risk management finance", - "AI financial markets", - ] - - for query in search_queries: - logger.info(f"Searching with query: {query}") - - for attempt in range(retry_count): - try: - papers = arxiv_source.search( - query=query, - max_results=3, - ) - - if not papers: - logger.warning(f"No papers found for query: {query}") - continue - - for paper in papers: - paper_id = paper.get_primary_id() - if paper_id not in local_storage._raw_files_index: - logger.info(f"Found unique paper: {paper.title}") - return paper - else: - logger.debug(f"Paper {paper_id} already processed") - - except Exception as e: - logger.error(f"Error searching (attempt {attempt + 1}): {e}") - if attempt == retry_count - 1: - logger.error( - f"Failed to search after {retry_count} attempts" - ) - - logger.warning("No unique papers found across all queries") - return None - - -def display_results(paper: Paper) -> None: - """Display pipeline results in a colorful, user-friendly format.""" - # Header - cprint("\n" + "=" * 80, Colors.HEADER) - cprint("🎯 QUANT PAPER AGENT PIPELINE RESULTS", Colors.HEADER, bold=True) - cprint("=" * 80, Colors.HEADER) - - # Paper Information - print_info("PAPER INFORMATION:") - cprint(f"Title: {paper.title}", Colors.OKBLUE, bold=True) - cprint(f"Authors: {', '.join(paper.authors)}", Colors.OKBLUE) - cprint(f"ArXiv ID: {paper.arxiv_id}", Colors.OKBLUE) - cprint(f"Categories: {', '.join(paper.categories)}", Colors.OKBLUE) - cprint(f"Published: {paper.published_date}", Colors.OKBLUE) - - # Display summary - summary = paper.meta_info.get("summary") - if summary: - print_step("📋", "INTELLIGENT SUMMARY:") - cprint("-" * 40, Colors.OKCYAN) - # Truncate summary for display if too long - display_summary = ( - summary[:500] + "..." if len(summary) > 500 else summary - ) - cprint(display_summary, Colors.ENDC) - - # Display QA questions - qa_data = paper.meta_info.get("qa_data") - if qa_data: - questions = qa_data.get("questions", []) - print_step("❓", f"THOUGHTFUL QUESTIONS ({len(questions)} generated):") - cprint("-" * 40, Colors.OKCYAN) - - for i, q_data in enumerate(questions, 1): - question = q_data.get("question", "") - category = q_data.get("category", "general") - difficulty = q_data.get("difficulty", "intermediate") - - # Color code by difficulty - difficulty_color = { - "basic": Colors.OKGREEN, - "intermediate": Colors.WARNING, - "advanced": Colors.FAIL, - }.get(difficulty.lower(), Colors.ENDC) - - cprint(f"\n{i}. {question}", Colors.ENDC, bold=True) - print( - f" {Colors.ENDC}Category: {Colors.OKCYAN}{category.title()}{Colors.ENDC} | Difficulty: {difficulty_color}{difficulty.title()}{Colors.ENDC}" - ) - - # Display metadata - print_step("🔧", "PROCESSING METADATA:") - cprint("-" * 40, Colors.OKCYAN) - if qa_data: - cprint(f"QA Model: {qa_data.get('model_used', 'Unknown')}", Colors.ENDC) - cprint( - f"Generated: {qa_data.get('generated_at', 'Unknown')}", Colors.ENDC - ) - cprint( - f"Question Count: {qa_data.get('question_count', 0)}", Colors.ENDC - ) - cprint( - f"Focus Areas: {', '.join(qa_data.get('focus_areas', []))}", - Colors.ENDC, - ) - - storage_path = paper.meta_info.get("storage_path", "Not stored") - cprint(f"\nStorage Path: {storage_path}", Colors.OKGREEN) - cprint("=" * 80, Colors.HEADER) - - -def main(): - """Execute the production quant paper agent pipeline.""" - print_header("Starting Production Quant Paper Agent Pipeline") - cprint( - "Using Gemini 2.5 Pro & Flash for advanced AI processing\n", - Colors.OKCYAN, - ) - - try: - # Step 1: Load configuration - config_path = Path(__file__).parent / "config.yaml" - if not config_path.exists(): - raise FileNotFoundError( - f"Configuration file not found: {config_path}" - ) - - logger.info("Loading configuration...") - settings = load_config(config_path) - - # Step 2: Initialize components - logger.info("Initializing pipeline components...") - local_storage = LocalStorage(settings.storage) - arxiv_source = ArxivSource(settings.source) - llama_parser = LlamaParser(settings.parser) - - # Initialize flows - summary_flow = SummaryFlow(settings.flows["summary_flow"]) - qa_flow = QAFlow(settings.flows["qa_flow"]) - - print_success("All components initialized successfully") - - # Step 3: Get unique paper - print_step("📡", "Searching for unique quantitative finance paper...") - paper = get_unique_paper(arxiv_source, local_storage) - if not paper: - print_error( - "No unique papers found. Try again later or check your search criteria." - ) - return - - print_success(f"Found paper: {paper.title[:60]}...") - - # Step 4: Download and store raw content - print_step("📥", "Downloading paper content...") - local_storage.process_knowledge(paper) - print_success("Paper downloaded successfully") - - # Step 5: Parse with LlamaParser - print_step("🔍", "Parsing PDF content with LlamaParser...") - paper = llama_parser.parse_paper(paper) - - if not paper.has_content(): - print_error("Failed to extract content from PDF") - return - - print_success(f"Content extracted ({len(paper.content)} characters)") - - # Step 6: Generate summary using Gemini 2.5 - print_step("📝", "Generating intelligent summary with Gemini 2.5...") - summary_result = summary_flow.run(paper) - - # Store summary in meta_info - paper.meta_info["summary"] = summary_result - paper.meta_info["summary_generated_at"] = paper.processed_at.isoformat() - - print_success("Summary generated successfully") - - # Step 7: Generate QA questions - print_step("❓", "Generating thoughtful questions with Gemini 2.5...") - qa_result = qa_flow.run(paper) - # QA results are automatically stored in paper.meta_info by the flow - - print_success(f"Generated {len(qa_result)} thoughtful questions") - - # Step 8: Store final enriched paper - print_step("💾", "Storing enriched paper with metadata...") - local_storage.store_knowledge(paper) - - # Add storage path to meta_info - storage_path = local_storage.get_knowledge_path(paper.get_primary_id()) - paper.meta_info["storage_path"] = str(storage_path) - - print_success("Paper stored with all metadata") - - # Step 9: Display results - display_results(paper) - - cprint( - f"\n🎉 Pipeline completed successfully!", Colors.OKGREEN, bold=True - ) - cprint(f"Paper stored at: {storage_path}", Colors.OKGREEN) - - except KeyboardInterrupt: - cprint("\n⏹️ Pipeline interrupted by user", Colors.WARNING, bold=True) - except Exception as e: - logger.error(f"Pipeline failed: {e}") - print_error(f"Pipeline failed: {e}") - cprint("💡 Make sure you have:", Colors.OKCYAN, bold=True) - cprint(" • Set GOOGLE_API_KEY environment variable", Colors.OKCYAN) - cprint( - " • Set LLAMA_CLOUD_API_KEY environment variable", Colors.OKCYAN - ) - cprint(" • Valid internet connection", Colors.OKCYAN) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/examples/sources/README.md b/examples/sources/README.md deleted file mode 100644 index ae74e94..0000000 --- a/examples/sources/README.md +++ /dev/null @@ -1,212 +0,0 @@ -# Sources Usage Examples - -This directory contains comprehensive examples demonstrating how to use the QuantMind sources module, specifically the ArXiv source implementation. - -## Files Overview - -### 1. `basic_arxiv_usage.py` -Demonstrates fundamental ArXiv source operations: -- Basic paper search -- Retrieving papers by ArXiv ID -- Getting recent papers by timeframe -- Batch retrieval of multiple papers -- PDF download functionality -- Category-specific searches - -**Run with:** -```bash -python examples/sources/basic_arxiv_usage.py -``` - -### 2. `advanced_configuration.py` -Shows advanced configuration options and scenarios: -- Finance-focused research configuration -- AI/ML research configuration -- Production-ready settings -- Loading configuration from YAML -- Configuration validation -- Comparing different configuration approaches - -**Run with:** -```bash -python examples/sources/advanced_configuration.py -``` - -## Key Features Demonstrated - -### Configuration Management -- **Pydantic-based validation**: All configurations use structured Pydantic models -- **Flexible initialization**: Accept both dict and config objects -- **Environment-specific settings**: Examples for development, research, and production -- **YAML support**: Load configurations from external files - -### Content Filtering -- **Category filtering**: Include/exclude specific arXiv categories -- **Quality controls**: Minimum abstract length requirements -- **Content validation**: Ensure papers meet quality standards - -### Download Management -- **Configurable downloads**: Enable/disable PDF downloads -- **Custom directories**: Specify download locations -- **Batch downloads**: Download multiple papers efficiently -- **Error handling**: Robust error handling for failed downloads - -### Rate Limiting -- **Respectful usage**: Built-in rate limiting to respect arXiv's servers -- **Configurable rates**: Adjust request frequency based on use case -- **Timeout handling**: Configurable timeouts for reliability - -## Configuration Examples - -### Basic Configuration -```python -from quantmind.config.sources import ArxivSourceConfig -from quantmind.sources.arxiv_source import ArxivSource - -# Simple configuration -config = ArxivSourceConfig( - max_results=10, - download_pdfs=True, - download_dir="./papers" -) - -source = ArxivSource(config=config) -``` - -### Finance Research Configuration -```python -config = ArxivSourceConfig( - include_categories=["q-fin.ST", "q-fin.TR", "q-fin.PM"], - min_abstract_length=150, - requests_per_second=0.5, - sort_by="submittedDate" -) -``` - -### Production Configuration -```python -config = ArxivSourceConfig( - max_results=100, - timeout=60, - retry_attempts=3, - requests_per_second=0.5, - min_abstract_length=200, - download_pdfs=True -) -``` - -## Usage Patterns - -### 1. Basic Search -```python -source = ArxivSource() -papers = source.search("machine learning", max_results=5) -``` - -### 2. Timeframe Queries -```python -# Get papers from last 7 days in AI categories -papers = source.get_by_timeframe( - days=7, - categories=["cs.AI", "cs.LG"] -) -``` - -### 3. Batch Retrieval -```python -paper_ids = ["1706.03762", "1512.03385"] -papers = source.get_batch(paper_ids) -``` - -### 4. PDF Downloads -```python -config = ArxivSourceConfig(download_pdfs=True, download_dir="./pdfs") -source = ArxivSource(config=config) -papers = source.search("neural networks", max_results=3) -paths = source.download_papers_pdfs(papers) -``` - -## Best Practices - -### 1. Rate Limiting -Always use appropriate rate limiting to be respectful to arXiv: -```python -config = ArxivSourceConfig(requests_per_second=1.0) # 1 request per second -``` - -### 2. Content Quality -Filter for high-quality content: -```python -config = ArxivSourceConfig( - min_abstract_length=100, # Ensure substantial abstracts - include_categories=["relevant", "categories"] # Focus on relevant areas -) -``` - -### 3. Error Handling -Always handle potential errors: -```python -try: - papers = source.search(query) - if not papers: - print("No papers found") -except Exception as e: - logger.error(f"Search failed: {e}") -``` - -### 4. Configuration Validation -Validate configurations before use: -```python -source = ArxivSource(config=config) -if source.validate_config(): - # Proceed with operations - pass -else: - # Handle invalid configuration - pass -``` - -## Advanced Features - -### 1. Custom Filtering -Implement additional filtering logic: -```python -papers = source.search(query, max_results=100) -filtered_papers = [p for p in papers if custom_filter(p)] -``` - -### 3. Batch Processing -Process papers in batches for efficiency: -```python -def process_batch(papers): - # Process batch of papers - paths = source.download_papers_pdfs(papers) - return paths - -papers = source.search(query, max_results=50) -batch_size = 10 -for i in range(0, len(papers), batch_size): - batch = papers[i:i+batch_size] - process_batch(batch) -``` - -## Notes - -- **ArXiv Compliance**: All examples follow arXiv's API usage guidelines -- **Error Handling**: Comprehensive error handling for network issues -- **Logging**: Built-in logging for debugging and monitoring -- **Testing**: Examples include test scenarios for validation -- **Performance**: Optimized for both small queries and large-scale research - -## Requirements - -To run these examples, ensure you have: -- `arxiv` Python package for API access -- `requests` for PDF downloads -- `pydantic` for configuration validation -- `pyyaml` for YAML configuration support - -Install requirements: -```bash -pip install arxiv requests pydantic pyyaml -``` diff --git a/examples/sources/advanced_configuration.py b/examples/sources/advanced_configuration.py deleted file mode 100644 index 160b545..0000000 --- a/examples/sources/advanced_configuration.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Advanced ArXiv source configuration examples. - -This example demonstrates advanced configuration options for the ArxivSource: -- Custom download directories -- Category filtering -- Rate limiting -- Content filtering -- Configuration validation -""" - -import yaml -from pathlib import Path -from tempfile import TemporaryDirectory - -from quantmind.config.sources import ArxivSourceConfig -from quantmind.sources.arxiv_source import ArxivSource -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - - -def finance_focused_config_example(): - """Demonstrate finance-focused configuration.""" - print("=== Finance-Focused Configuration ===") - - config = ArxivSourceConfig( - # API settings - max_results=20, - sort_by="submittedDate", - sort_order="descending", - # Content filtering for finance - include_categories=[ - "q-fin.ST", # Statistical Finance - "q-fin.TR", # Trading and Market Microstructure - "q-fin.PM", # Portfolio Management - "q-fin.RM", # Risk Management - "q-fin.CP", # Computational Finance - ], - min_abstract_length=150, # Longer abstracts for quality - # Rate limiting to be respectful - requests_per_second=0.8, - timeout=45, - # Download settings - download_pdfs=True, - ) - - print("Configuration created:") - print(f"- Max results: {config.max_results}") - print(f"- Include categories: {config.include_categories}") - print(f"- Min abstract length: {config.min_abstract_length}") - print(f"- Requests per second: {config.requests_per_second}") - print(f"- Download PDFs: {config.download_pdfs}") - - return config - - -def ai_research_config_example(): - """Demonstrate AI research configuration.""" - print("\n=== AI Research Configuration ===") - - config = ArxivSourceConfig( - # API settings optimized for AI research - max_results=50, - sort_by="relevance", - sort_order="descending", - # AI/ML categories - include_categories=[ - "cs.AI", # Artificial Intelligence - "cs.LG", # Machine Learning - "cs.CV", # Computer Vision - "cs.CL", # Computation and Language - "cs.NE", # Neural and Evolutionary Computing - "stat.ML", # Machine Learning (Statistics) - ], - # Exclude some categories we're not interested in - exclude_categories=[ - "cs.CR", # Cryptography and Security - "cs.SE", # Software Engineering - ], - # Quality filters - min_abstract_length=100, - # Higher rate for research use - requests_per_second=1.5, - # No downloads for this config - download_pdfs=False, - ) - - print("AI Research configuration:") - print(f"- Focus areas: {len(config.include_categories)} categories") - print(f"- Excluded: {config.exclude_categories}") - print(f"- Sort by: {config.sort_by}") - - return config - - -def production_config_example(): - """Demonstrate production-ready configuration.""" - print("\n=== Production Configuration ===") - - with TemporaryDirectory() as temp_dir: - download_dir = Path(temp_dir) / "arxiv_papers" - - config = ArxivSourceConfig( - # Conservative settings for production - max_results=100, - timeout=60, - retry_attempts=3, - requests_per_second=0.5, # Very conservative - # Content quality controls - min_abstract_length=200, - # Download setup - download_pdfs=True, - download_dir=download_dir, - # Broad categories for general research - include_categories=[ - "cs.AI", - "cs.LG", - "stat.ML", # AI/ML - "q-fin.ST", - "q-fin.TR", - "q-fin.PM", # Finance - "math.OC", - "math.PR", - "math.ST", # Math - ], - ) - - print("Production configuration:") - print(f"- Download directory: {config.download_dir}") - print(f"- Timeout: {config.timeout}s") - print(f"- Retry attempts: {config.retry_attempts}") - print(f"- Rate limit: {config.requests_per_second} req/s") - - return config - - -def config_from_yaml_example(): - """Demonstrate loading configuration from YAML.""" - print("\n=== Configuration from YAML ===") - - yaml_config = """ - max_results: 25 - sort_by: "submittedDate" - sort_order: "descending" - - # Download settings - download_pdfs: true - - # Quality filters - min_abstract_length: 120 - - # Categories of interest - include_categories: - - "q-fin.ST" - - "q-fin.TR" - - "cs.AI" - - "stat.ML" - - # Rate limiting - requests_per_second: 1.0 - timeout: 30 - """ - - # Parse YAML - yaml_data = yaml.safe_load(yaml_config) - - # Create config from dictionary - config = ArxivSourceConfig(**yaml_data) - - print("Configuration loaded from YAML:") - print(f"- Max results: {config.max_results}") - print(f"- Categories: {len(config.include_categories)}") - print(f"- Download PDFs: {config.download_pdfs}") - - return config - - -def test_configurations(): - """Test different configurations with actual searches.""" - print("\n=== Testing Configurations ===") - - # Test finance config - finance_config = finance_focused_config_example() - finance_source = ArxivSource(config=finance_config) - - print("\nTesting finance configuration:") - if finance_source.validate_config(): - papers = finance_source.search("portfolio optimization", max_results=3) - print(f"✓ Found {len(papers)} finance papers") - for paper in papers: - print(f" - {paper.title[:50]}...") - else: - print("✗ Finance configuration invalid") - - # Test AI config - ai_config = ai_research_config_example() - ai_source = ArxivSource(config=ai_config) - - print("\nTesting AI configuration:") - if ai_source.validate_config(): - papers = ai_source.search("transformer neural network", max_results=3) - print(f"✓ Found {len(papers)} AI papers") - for paper in papers: - print(f" - {paper.title[:50]}...") - else: - print("✗ AI configuration invalid") - - -def config_validation_example(): - """Demonstrate configuration validation.""" - print("\n=== Configuration Validation ===") - - # Valid configuration - try: - valid_config = ArxivSourceConfig( - max_results=10, sort_by="relevance", requests_per_second=1.0 - ) - print("✓ Valid configuration created successfully") - except Exception as e: - print(f"✗ Valid configuration failed: {e}") - - # Invalid configurations - invalid_configs = [ - {"sort_by": "invalid_sort"}, - {"sort_order": "invalid_order"}, - {"max_results": -1}, - {"requests_per_second": 0}, - ] - - for i, invalid_config in enumerate(invalid_configs, 1): - try: - ArxivSourceConfig(**invalid_config) - print(f"✗ Invalid config {i} should have failed!") - except Exception as e: - print( - f"✓ Invalid config {i} correctly rejected: {type(e).__name__}" - ) - - -def compare_configurations(): - """Compare search results across different configurations.""" - print("\n=== Configuration Comparison ===") - - query = "machine learning finance" - - configs = { - "Default": ArxivSourceConfig(), - "Relevance": ArxivSourceConfig(sort_by="relevance"), - "Recent": ArxivSourceConfig(sort_by="submittedDate"), - "Finance-only": ArxivSourceConfig( - include_categories=["q-fin.ST", "q-fin.TR"] - ), - } - - for name, config in configs.items(): - source = ArxivSource(config=config) - papers = source.search(query, max_results=3) - - print(f"\n{name} configuration:") - print(f" Found {len(papers)} papers") - if papers: - print(f" Top result: {papers[0].title[:60]}...") - print(f" Categories: {papers[0].categories}") - - -def main(): - """Run all configuration examples.""" - examples = [ - config_validation_example, - config_from_yaml_example, - test_configurations, - compare_configurations, - ] - - for i, example in enumerate(examples, 1): - try: - print(f"\n{'=' * 70}") - print(f"Configuration Example {i}/{len(examples)}") - example() - except Exception as e: - logger.error(f"Error in configuration example {i}: {e}") - - # Small delay between examples - if i < len(examples): - import time - - time.sleep(0.5) - - print(f"\n{'=' * 70}") - print("All configuration examples completed!") - - -if __name__ == "__main__": - main() diff --git a/examples/sources/basic_arxiv_usage.py b/examples/sources/basic_arxiv_usage.py deleted file mode 100644 index cd5878f..0000000 --- a/examples/sources/basic_arxiv_usage.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Basic ArXiv source usage example. - -This example demonstrates how to use the ArxivSource class for: -- Basic paper search -- Retrieving papers by ID -- Getting recent papers -- Configuring the source with different settings -""" - -from pathlib import Path -from tempfile import TemporaryDirectory - -from quantmind.config.sources import ArxivSourceConfig -from quantmind.sources.arxiv_source import ArxivSource -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - - -def basic_search_example(): - """Demonstrate basic search functionality.""" - print("=== Basic Search Example ===") - - # Create source with default configuration - source = ArxivSource() - - # Search for machine learning papers - papers = source.search("machine learning", max_results=5) - - print(f"Found {len(papers)} papers:") - for i, paper in enumerate(papers, 1): - print(f"{i}. {paper.title}") - print( - f" Authors: {', '.join(paper.authors[:3])}{'...' if len(paper.authors) > 3 else ''}" - ) - print(f" Categories: {', '.join(paper.categories)}") - print(f" ArXiv ID: {paper.arxiv_id}") - print() - - -def configured_search_example(): - """Demonstrate search with custom configuration.""" - print("=== Configured Search Example ===") - - # Create configuration for finance-focused search - config = ArxivSourceConfig( - max_results=10, - sort_by="relevance", - sort_order="descending", - include_categories=["q-fin.ST", "q-fin.TR", "q-fin.PM"], - min_abstract_length=100, - requests_per_second=0.5, # Slower to be respectful - ) - - source = ArxivSource(config=config) - - # Search for quantitative finance papers - papers = source.search("portfolio optimization", max_results=3) - - print(f"Found {len(papers)} finance papers:") - for paper in papers: - print(f"- {paper.title}") - print(f" Categories: {', '.join(paper.categories)}") - print(f" Abstract length: {len(paper.abstract)} chars") - print() - - -def get_by_id_example(): - """Demonstrate retrieving papers by ID.""" - print("=== Get by ID Example ===") - - source = ArxivSource() - - # Try to get a specific paper by arXiv ID - paper_ids = [ - "2301.12345", - "1706.03762", - ] # Second one is "Attention Is All You Need" - - for paper_id in paper_ids: - paper = source.get_by_id(paper_id) - if paper: - print(f"Found paper: {paper.title}") - print(f"Authors: {', '.join(paper.authors)}") - print(f"Published: {paper.published_date}") - print() - else: - print(f"Paper {paper_id} not found") - - -def recent_papers_example(): - """Demonstrate getting recent papers.""" - print("=== Recent Papers Example ===") - - source = ArxivSource() - - # Get recent AI papers from the last 3 days - papers = source.get_by_timeframe(days=3, categories=["cs.AI", "cs.LG"]) - - print(f"Found {len(papers)} recent AI/ML papers:") - for paper in papers[:5]: # Show first 5 - print(f"- {paper.title}") - print(f" Published: {paper.published_date}") - print() - - -def download_example(): - """Demonstrate PDF download functionality.""" - print("=== PDF Download Example ===") - - with TemporaryDirectory() as temp_dir: - # Configure source with PDF downloads enabled - config = ArxivSourceConfig( - download_pdfs=True, - download_dir=Path(temp_dir), - max_results=2, - requests_per_second=0.5, - proxies={ - "http": "http://127.0.0.1:7890", - "https": "http://127.0.0.1:7890", - "all_proxy": "socks5://127.0.0.1:7890", - }, - ) - - source = ArxivSource(config=config) - - # Search for a few papers - papers = source.search("attention mechanism", max_results=2) - - if papers: - print(f"Downloading PDFs for {len(papers)} papers...") - - # Download PDFs - download_paths = source.download_papers_pdfs(papers) - - for paper, path in zip(papers, download_paths): - if path: - print(f"✓ Downloaded: {paper.title}") - print(f" File: {path.name}") - print(f" Size: {path.stat().st_size} bytes") - else: - print(f"✗ Failed to download: {paper.title}") - print() - else: - print("No papers found for download example") - - -def batch_retrieval_example(): - """Demonstrate batch retrieval of papers.""" - print("=== Batch Retrieval Example ===") - - source = ArxivSource() - - # List of paper IDs to retrieve - paper_ids = [ - "1706.03762", # Attention Is All You Need - "1512.03385", # ResNet - "1409.1556", # GAN - "nonexistent", # This one won't be found - ] - - papers = source.get_batch(paper_ids) - - print(f"Requested {len(paper_ids)} papers, found {len(papers)}:") - for paper in papers: - print(f"- {paper.title}") - print(f" ArXiv ID: {paper.arxiv_id}") - print() - - -def category_search_example(): - """Demonstrate category-specific search.""" - print("=== Category Search Example ===") - - source = ArxivSource() - - # Search in specific categories - categories = ["q-fin.ST", "cs.AI"] - - for category in categories: - papers = source.search_by_category(category, max_results=3) - print(f"Category {category}: {len(papers)} papers") - - for paper in papers: - print(f" - {paper.title[:60]}...") - print() - - -def main(): - """Run all examples.""" - examples = [ - basic_search_example, - configured_search_example, - get_by_id_example, - recent_papers_example, - batch_retrieval_example, - category_search_example, - download_example, # This one creates files, so run it last - ] - - for i, example in enumerate(examples, 1): - try: - print(f"\n{'=' * 60}") - print(f"Example {i}/{len(examples)}") - example() - except Exception as e: - logger.error(f"Error in example {i}: {e}") - - # Add a small delay between examples to be respectful to arXiv - if i < len(examples): - import time - - time.sleep(1) - - print(f"\n{'=' * 60}") - print("All examples completed!") - - -if __name__ == "__main__": - main() diff --git a/examples/storage/local_storage_usage.py b/examples/storage/local_storage_usage.py deleted file mode 100644 index 245ce46..0000000 --- a/examples/storage/local_storage_usage.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Local storage usage example for QuantMind. - -This example demonstrates: -1. Storing raw files from content bytes directly -2. Using process_knowledge for Paper objects -3. Automatic PDF downloading for Paper objects -""" - -from datetime import datetime, timezone -from pathlib import Path - -from quantmind.config import LocalStorageConfig -from quantmind.models import Paper -from quantmind.storage import LocalStorage - - -def demonstrate_enhanced_raw_file_storage(): - """Demonstrate storing raw files from content.""" - print("=== Enhanced Raw File Storage Demo ===") - - # Initialize storage - config = LocalStorageConfig(storage_dir=Path("./demo_data")) - storage = LocalStorage(config) - - # Example 1: Store content directly as bytes - print("\n1. Storing content directly as bytes:") - - # Simulate PDF content downloaded from ArXiv - pdf_content = b"%PDF-1.4\n1 0 obj<>endobj" - - # Store PDF content directly - pdf_path = storage.store_raw_file( - file_id="paper_1", content=pdf_content, file_extension=".pdf" - ) - print(f" Stored PDF content to: {pdf_path}") - - # Store text content - text_content = "# Research Paper Abstract\n\nSample content".encode("utf-8") - - text_path = storage.store_raw_file( - file_id="paper_1_abstract", content=text_content, file_extension=".md" - ) - print(f" Stored text content to: {text_path}") - - return storage - - -def demonstrate_paper_specialized_storage(): - """Demonstrate Paper-specific storage with automatic handling.""" - print("\n=== Paper Specialized Storage Demo ===") - - # Initialize storage - config = LocalStorageConfig(storage_dir=Path("./demo_data")) - storage = LocalStorage(config) - - # Paper with PDF URL - paper_with_pdf = Paper( - title="Machine Learning in Quantitative Finance", - abstract="This paper explores ML techniques in quantitative finance.", - authors=["John Smith", "Jane Doe"], - arxiv_id="2024.0001", - pdf_url="https://arxiv.org/pdf/2024.0001.pdf", - categories=["q-fin.CP", "cs.LG"], - published_date=datetime.now(timezone.utc), - source="arxiv", - ) - - # Store with specialized handling - paper_id = storage.process_knowledge(paper_with_pdf) - print(f" Stored paper with ID: {paper_id}") - - return storage - - -def main(): - """Main demonstration function.""" - print("🚀 QuantMind Enhanced Storage Demonstration") - - try: - demonstrate_enhanced_raw_file_storage() - demonstrate_paper_specialized_storage() - print(f"\n🎉 All demonstrations completed successfully!") - - except Exception as e: - print(f"\n❌ Error during demonstration: {e}") - raise - - -if __name__ == "__main__": - main() diff --git a/examples/storage/storage_performance_demo.py b/examples/storage/storage_performance_demo.py deleted file mode 100644 index 8eccfd0..0000000 --- a/examples/storage/storage_performance_demo.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Storage performance demonstration with and without indexing. - -This script demonstrates the dramatic performance improvement achieved -by the new indexing system in LocalStorage. -""" - -import time -from pathlib import Path -from datetime import datetime, timezone - -from quantmind.config.storage import LocalStorageConfig -from quantmind.storage.local_storage import LocalStorage -from quantmind.models.paper import Paper - - -def create_test_data(storage: LocalStorage, num_items: int = 100): - """Create test data for performance testing.""" - print(f"Creating {num_items} test items...") - - for i in range(num_items): - # Create test papers - paper = Paper( - title=f"Test Paper {i}", - abstract=f"This is test paper number {i} for performance testing.", - authors=[f"Author {i}"], - arxiv_id=f"test.{i:04d}", - categories=["q-fin.CP"], - published_date=datetime.now(timezone.utc), - source="test", - ) - storage.store_knowledge(paper) - - # Create test raw files - content = f"Test content for file {i}".encode() - storage.store_raw_file( - f"test_file_{i}", content=content, file_extension=".txt" - ) - - # Create test embeddings - embedding = [float(j) for j in range(10)] # Simple embedding - storage.store_embedding(f"test.{i:04d}", embedding, "test_model") - - print(f"✅ Created {num_items} items successfully") - - -def simulate_old_behavior(storage: LocalStorage, num_lookups: int = 50): - """Simulate old file system scanning behavior for comparison.""" - print(f"\n🐌 Simulating old behavior (directory scanning)...") - - start_time = time.time() - - for i in range(num_lookups): - # Simulate directory scanning by manually checking files - file_id = f"test_file_{i}" - found = False - - # This simulates the old glob-based search - for file_path in storage.config.raw_files_dir.glob(f"{file_id}.*"): - if file_path.is_file(): - found = True - break - - end_time = time.time() - old_time = end_time - start_time - - print(f" Time for {num_lookups} lookups: {old_time:.4f} seconds") - print(f" Average per lookup: {(old_time / num_lookups) * 1000:.2f} ms") - - return old_time - - -def test_new_indexing_performance(storage: LocalStorage, num_lookups: int = 50): - """Test the new indexing system performance.""" - print(f"\n🚀 Testing new indexing system...") - - start_time = time.time() - - for i in range(num_lookups): - # Use the new index-based lookup - file_id = f"test_file_{i}" - file_path = storage.get_raw_file(file_id) - # Just verify it exists - assert file_path is not None - - end_time = time.time() - new_time = end_time - start_time - - print(f" Time for {num_lookups} lookups: {new_time:.4f} seconds") - print(f" Average per lookup: {(new_time / num_lookups) * 1000:.2f} ms") - - return new_time - - -def test_knowledge_lookup_performance( - storage: LocalStorage, num_lookups: int = 50 -): - """Test knowledge lookup performance.""" - print(f"\n📚 Testing knowledge lookup performance...") - - start_time = time.time() - - for i in range(num_lookups): - knowledge_id = f"test.{i:04d}" - knowledge = storage.get_knowledge(knowledge_id) - assert knowledge is not None - - end_time = time.time() - lookup_time = end_time - start_time - - print( - f" Time for {num_lookups} knowledge lookups: {lookup_time:.4f} seconds" - ) - print(f" Average per lookup: {(lookup_time / num_lookups) * 1000:.2f} ms") - - return lookup_time - - -def test_batch_operations(storage: LocalStorage): - """Test batch operations performance.""" - print(f"\n📦 Testing batch operations...") - - # Test get_all_knowledges (now uses index) - start_time = time.time() - all_knowledges = list(storage.get_all_knowledges()) - end_time = time.time() - - batch_time = end_time - start_time - count = len(all_knowledges) - - print(f" Retrieved {count} knowledge items in {batch_time:.4f} seconds") - print(f" Average per item: {(batch_time / count) * 1000:.2f} ms") - - return batch_time - - -def test_index_rebuild_performance(storage: LocalStorage): - """Test index rebuilding performance.""" - print(f"\n🔄 Testing index rebuild performance...") - - start_time = time.time() - storage.rebuild_all_indexes() - end_time = time.time() - - rebuild_time = end_time - start_time - - print(f" Index rebuild time: {rebuild_time:.4f} seconds") - print(f" Raw files indexed: {len(storage._raw_files_index)}") - print(f" Knowledge items indexed: {len(storage._knowledges_index)}") - print(f" Embeddings indexed: {len(storage._embeddings_index)}") - - return rebuild_time - - -def show_storage_statistics(storage: LocalStorage): - """Show detailed storage statistics.""" - print(f"\n📊 Storage Statistics:") - - info = storage.get_storage_info() - - print(f" Storage Directory: {info['storage_dir']}") - print(f" Knowledge Count: {info['knowledge_count']}") - print(f" Raw Files Count: {info['raw_files_count']}") - print(f" Embeddings Count: {info['embeddings_count']}") - - print(f"\n Index Statistics:") - for index_type, stats in info["indexes"].items(): - print(f" {index_type}: {stats['entries']} entries") - print(f" Index file: {Path(stats['index_file']).name}") - - -def main(): - """Main performance demonstration.""" - print("🎯 QuantMind Storage Performance Demonstration") - print("=" * 60) - - # Setup - demo_dir = Path("./performance_demo_data") - if demo_dir.exists(): - import shutil - - shutil.rmtree(demo_dir) - - config = LocalStorageConfig(storage_dir=demo_dir) - storage = LocalStorage(config) - - try: - # Create test data - num_items = 100 - num_lookups = 50 - - create_test_data(storage, num_items) - - # Performance tests - old_time = simulate_old_behavior(storage, num_lookups) - new_time = test_new_indexing_performance(storage, num_lookups) - knowledge_time = test_knowledge_lookup_performance(storage, num_lookups) - batch_time = test_batch_operations(storage) - rebuild_time = test_index_rebuild_performance(storage) - - # Calculate improvement - if old_time > 0: - speedup = old_time / new_time - improvement = ((old_time - new_time) / old_time) * 100 - else: - speedup = float("inf") - improvement = 100 - - # Summary - print(f"\n" + "=" * 60) - print(f"🎉 PERFORMANCE SUMMARY") - print(f"=" * 60) - print(f" Test Items: {num_items}") - print(f" Lookups Tested: {num_lookups}") - print(f"") - print(f" Old Method (directory scan): {old_time:.4f}s") - print(f" New Method (index lookup): {new_time:.4f}s") - print(f"") - print(f" 🚀 Speedup: {speedup:.1f}x faster") - print(f" 📈 Improvement: {improvement:.1f}% faster") - print(f"") - print(f" 📚 Knowledge lookup time: {knowledge_time:.4f}s") - print(f" 📦 Batch retrieval time: {batch_time:.4f}s") - print(f" 🔄 Index rebuild time: {rebuild_time:.4f}s") - - # Show storage statistics - show_storage_statistics(storage) - - print(f"\n✨ Key Benefits:") - print(f" • O(1) lookup time vs O(n) directory scanning") - print(f" • Persistent indexes survive restarts") - print(f" • Automatic index rebuilding for data recovery") - print(f" • Self-healing: removes stale entries automatically") - print(f" • Fallback to directory scan for missing entries") - - print(f"\n📁 Demo data saved in: {demo_dir}") - print(f" Check the index files in: {demo_dir}/extra/") - - except Exception as e: - print(f"\n❌ Error during demonstration: {e}") - raise - - -if __name__ == "__main__": - main() diff --git a/examples/tagger/llm_tagger_example.py b/examples/tagger/llm_tagger_example.py deleted file mode 100644 index 995134d..0000000 --- a/examples/tagger/llm_tagger_example.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Example: LLM tagging for research papers.""" - -import os - -from quantmind.config import LLMTaggerConfig -from quantmind.config.llm import LLMConfig -from quantmind.models.paper import Paper -from quantmind.tagger.llm_tagger import LLMTagger - - -def main(): - """Demonstrate simple LLM tagging.""" - # Create sample paper - paper = Paper( - title="LSTM Networks for High-Frequency Bitcoin Trading", - abstract="""This study implements Long Short-Term Memory (LSTM) neural networks - for predicting Bitcoin price movements in high-frequency trading scenarios. - We use order book data and sentiment analysis from Twitter to train our model, - achieving a Sharpe ratio of 1.8 over a 6-month period.""", - authors=["Alice Johnson", "Bob Chen"], - url="https://example.com/bitcoin-lstm-paper.pdf", - ) - - # Basic usage with defaults - Method 1: Using the convenient create() method - print("=== Basic Usage (using create method) ===") - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OPENAI_API_KEY environment variable is required") - - tagger = LLMTagger( - config=LLMTaggerConfig.create( - model="gpt-4o-mini", - api_key=api_key, - ) - ) - - tagged_paper = tagger.tag_paper(paper) - - print(f"Paper: {tagged_paper.title}") - print(f"Generated Tags: {tagged_paper.tags}") - print(f"Metadata: {tagged_paper.meta_info}") - - # Method 2: Using explicit LLMConfig composition - print("\n=== Alternative Configuration (explicit LLMConfig) ===") - if api_key: - llm_config = LLMConfig( - model="gpt-4o-mini", - api_key=api_key, - temperature=0.3, - ) - - tagger2 = LLMTagger( - config=LLMTaggerConfig( - llm_config=llm_config, - max_tags=5, - ) - ) - - tagged_paper_alt = tagger2.tag_paper(paper) - print(f"Paper: {tagged_paper_alt.title}") - print(f"Generated Tags: {tagged_paper_alt.tags}") - - # Custom configuration with user instructions - print("\n=== Custom Configuration (with user instructions) ===") - if api_key: # Only run if API key is available - custom_tagger = LLMTagger( - config=LLMTaggerConfig.create( - model="gpt-4o-mini", - api_key=api_key, - custom_instructions="Use - to connect tags, like deep-learning.", - max_tags=3, - temperature=0.1, - ) - ) - - # Create another paper - paper2 = Paper( - title="Portfolio Optimization Using Reinforcement Learning", - abstract="We apply deep Q-learning to portfolio allocation in equity markets.", - authors=["Carol Smith"], - ) - - tagged_paper2 = custom_tagger.tag_paper(paper2) - print(f"Paper: {tagged_paper2.title}") - print(f"Generated Tags: {tagged_paper2.tags}") - else: - print("OPENAI_API_KEY not found, skipping custom configuration example") - - # Extract tags from arbitrary text - # print("\n=== Text Analysis ===") - # text = "This paper discusses volatility modeling in forex markets using GARCH models." - # tags = tagger.extract_tags(text, "Volatility Study") - # print(f"Tags from text: {tags}") - - -if __name__ == "__main__": - main() diff --git a/examples/tools/basic_tool.py b/examples/tools/basic_tool.py deleted file mode 100644 index 7074879..0000000 --- a/examples/tools/basic_tool.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Basic example of creating and using a QuantMind tool.""" - -from quantmind.tools import tool, validate_tool_arguments - - -@tool -def calculate_position_value( - price: float, quantity: float, side: str = "long" -) -> float: - """Compute the signed notional value for a position. - - Args: - price (float): Latest unit price in dollars. - quantity (float): Position size in units. - side (str): Trading direction (choices: ["long", "short"]) - - Returns: - float: Signed notional value for the position. - """ - direction = 1 if side == "long" else -1 - return direction * price * quantity - - -def main(): - """Run the tool, validate inputs, and display metadata.""" - payload = {"price": 420.5, "quantity": 2, "side": "long"} - validate_tool_arguments(calculate_position_value, payload) - result = calculate_position_value(**payload) - print(f"Position value: {result}") - print("Tool inputs:") - for name, schema in calculate_position_value.inputs.items(): - print(f" {name}: {schema}") - - -if __name__ == "__main__": - main() diff --git a/examples/tools/pricing_tool.py b/examples/tools/pricing_tool.py deleted file mode 100644 index 136034d..0000000 --- a/examples/tools/pricing_tool.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Lightweight tool calculating portfolio metrics.""" - -from quantmind.tools import tool - - -@tool -def estimate_greeks(delta: float, gamma: float, underlying_move: float) -> dict: - """Estimate option PnL impact from delta and gamma. - - Args: - delta (float): Delta exposure of the option book. - gamma (float): Gamma exposure of the option book. - underlying_move (float): Expected underlying price change. - - Returns: - dict: Estimated delta and gamma contribution. - """ - delta_pnl = delta * underlying_move - gamma_pnl = 0.5 * gamma * underlying_move**2 - return { - "delta_contribution": delta_pnl, - "gamma_contribution": gamma_pnl, - } - - -def main(): - """Run the Greeks estimator with example exposures.""" - pnl = estimate_greeks(delta=1200, gamma=-45, underlying_move=0.8) - print("Estimated option PnL contributions:") - for key, value in pnl.items(): - print(f" {key}: {value:.2f}") - - -if __name__ == "__main__": - main() diff --git a/examples/tools/text_stats_tool.py b/examples/tools/text_stats_tool.py deleted file mode 100644 index 94a014b..0000000 --- a/examples/tools/text_stats_tool.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Text statistics tool demo.""" - -from quantmind.tools import tool - - -@tool -def summarize_text(text: str) -> dict: - """Compute simple statistics for a text snippet. - - Args: - text (str): Input paragraph to analyze. - - Returns: - dict: Contains character and word counts. - """ - words = [word for word in text.split() if word] - return { - "characters": len(text), - "words": len(words), - } - - -def main(): - """Run the text statistics tool with a sample snippet.""" - sample = "Markets rally as investors digest the latest earnings reports." - stats = summarize_text(sample) - print(f"Text: {sample}") - print(f"Characters: {stats['characters']}, Words: {stats['words']}") - - -if __name__ == "__main__": - main() diff --git a/examples/utils/logger_demo.py b/examples/utils/logger_demo.py deleted file mode 100644 index a94d02d..0000000 --- a/examples/utils/logger_demo.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env python3 -"""Demonstration of QuantMind's colored logging functionality.""" - -import logging -import time - -from quantmind.utils.logger import ( - get_logger, - configure_logging, - create_demo_logger, -) - - -def basic_logging_demo(): - """Demonstrate basic colored logging.""" - print("=== Basic Colored Logging Demo ===") - - # Get a logger for this module - logger = get_logger(__name__) - - # Log messages at different levels - logger.debug("Debug message - usually for development") - logger.info("Info message - general information") - logger.warning("Warning message - something to pay attention to") - logger.error("Error message - something went wrong") - logger.critical("Critical message - severe error!") - - print() - - -def module_specific_logging(): - """Demonstrate module-specific logging.""" - print("=== Module-Specific Logging ===") - - # Create loggers for different modules - arxiv_logger = get_logger("quantmind.sources.arxiv_source") - parser_logger = get_logger("quantmind.parsers.pdf_parser") - workflow_logger = get_logger("quantmind.workflow.agent") - - # Each logger maintains its own identity - arxiv_logger.info("ArXiv source: Found 10 papers") - parser_logger.warning("PDF parser: Document formatting is unusual") - workflow_logger.error("Workflow agent: Task failed with timeout") - - print() - - -def configuration_demo(): - """Demonstrate different logging configurations.""" - print("=== Configuration Options Demo ===") - - # Configure global logging - print("1. Debug level with colors:") - debug_logger = get_logger("debug_demo", level=logging.DEBUG, use_color=True) - debug_logger.debug("This debug message is now visible") - debug_logger.info("Info message with debug level") - - print("\n2. No colors (simulating non-TTY environment):") - no_color_logger = get_logger("no_color_demo", use_color=False) - no_color_logger.info("This message has no colors") - no_color_logger.error("This error message also has no colors") - - print("\n3. Custom format with file output:") - import tempfile - import os - - temp_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".log", delete=False - ) - temp_file.close() - - try: - file_logger = get_logger("file_demo", file_output=temp_file.name) - file_logger.info("This message goes to both console and file") - file_logger.warning("File logging is useful for production") - - # Show file contents - with open(temp_file.name, "r") as f: - file_contents = f.read() - print(f"\nFile contents:\n{file_contents}") - - finally: - os.unlink(temp_file.name) - - print() - - -def real_world_example(): - """Demonstrate real-world usage scenario.""" - print("=== Real-World Example: ArXiv Source ===") - - # Simulate ArXiv source operations - logger = get_logger("quantmind.sources.arxiv") - - logger.info("Initializing ArXiv source with configuration") - time.sleep(0.5) - - logger.info("Searching for papers: 'machine learning finance'") - time.sleep(0.5) - - logger.warning("Rate limiting: Waiting 1 second between requests") - time.sleep(0.5) - - logger.info("Found 15 papers matching query") - time.sleep(0.5) - - logger.info("Downloading PDF: paper_2301.12345.pdf") - time.sleep(0.5) - - logger.error("Failed to download PDF: Network timeout") - time.sleep(0.5) - - logger.info("Retrying download with exponential backoff") - time.sleep(0.5) - - logger.info("Successfully downloaded 14/15 papers") - - print() - - -def environment_awareness_demo(): - """Demonstrate environment-aware color detection.""" - print("=== Environment Awareness Demo ===") - - import os - import sys - - # Show current environment detection - is_tty = hasattr(sys.stderr, "isatty") and sys.stderr.isatty() - term_type = os.environ.get("TERM", "unknown") - no_color = os.environ.get("NO_COLOR") - - print(f"Terminal detection:") - print(f" - Is TTY: {is_tty}") - print(f" - TERM environment: {term_type}") - print(f" - NO_COLOR set: {no_color is not None}") - - # Auto-detected logger - auto_logger = get_logger("auto_detect") - print(f"\nAuto-detected color usage:") - auto_logger.info("This message uses auto-detected color settings") - - # Test with NO_COLOR environment variable - print(f"\nTesting NO_COLOR environment variable:") - os.environ["NO_COLOR"] = "1" - try: - no_color_auto = get_logger("no_color_auto") - no_color_auto.info("This should have no colors due to NO_COLOR=1") - finally: - del os.environ["NO_COLOR"] - - print() - - -def performance_demo(): - """Demonstrate logging performance with colors.""" - print("=== Performance Demo ===") - - import time - - # Test performance with colors - colored_logger = get_logger("perf_colored", use_color=True) - plain_logger = get_logger("perf_plain", use_color=False) - - # Measure colored logging time - start_time = time.time() - for i in range(100): - colored_logger.info(f"Colored log message {i}") - colored_time = time.time() - start_time - - # Measure plain logging time - start_time = time.time() - for i in range(100): - plain_logger.info(f"Plain log message {i}") - plain_time = time.time() - start_time - - print(f"Performance comparison (100 messages):") - print(f" - Colored logging: {colored_time:.4f} seconds") - print(f" - Plain logging: {plain_time:.4f} seconds") - print( - f" - Overhead: {((colored_time - plain_time) / plain_time * 100):.2f}%" - ) - - print() - - -def main(): - """Run all logging demonstrations.""" - print("QuantMind Colored Logging Demonstration") - print("=" * 50) - print() - - # Configure global logging for demos - configure_logging(level=logging.DEBUG, use_color=True) - - demos = [ - basic_logging_demo, - module_specific_logging, - configuration_demo, - real_world_example, - environment_awareness_demo, - # performance_demo, # Commented out to reduce output - ] - - for demo in demos: - try: - demo() - except Exception as e: - logger = get_logger(__name__) - logger.error(f"Demo failed: {e}") - - print("=== Final Demo: All Log Levels ===") - create_demo_logger() - - print("\n" + "=" * 50) - print("Logging demonstration complete!") - print("The colored output should help distinguish different log levels.") - - -if __name__ == "__main__": - main() diff --git a/examples/utils/prompts.yaml b/examples/utils/prompts.yaml deleted file mode 100644 index 97babb6..0000000 --- a/examples/utils/prompts.yaml +++ /dev/null @@ -1,35 +0,0 @@ -paper_analysis: | - Please analyze the following research paper and provide trading insights: - - Title: {{ title }} - Abstract: {{ abstract }} - Keywords: {{ keywords }} - - Focus on: - - Key quantitative methods and their trading applications - - Potential alpha signals or risk factors identified - - Backtesting considerations and implementation challenges - - Market conditions where this strategy might be most effective - - -# Local prompts for testing relative path loading - -local_prompt: | - This is a local prompt template for testing. - - Custom Parameter: {{ custom_param }} - Local Context: {{ local_context }} - -simple_test: | - Simple test template with {{ variable }}. - -conditional_test: | - {% if condition %} - Condition is true: {{ value }} - {% else %} - Condition is false: {{ value }} - {% endif %} - -nested_test: - key1: "Value 1: {{ var1 }}" - key2: "Value 2: {{ var2 }}" diff --git a/examples/utils/tmp_usage.py b/examples/utils/tmp_usage.py deleted file mode 100644 index 5f7bbe2..0000000 --- a/examples/utils/tmp_usage.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Simple example of QuantMind Template system usage.""" - -from quantmind.utils import T - - -def example_basic_usage(): - """Simple template usage example.""" - # Load and render a prompt template - prompt = T("examples.utils.prompts:paper_analysis").r( - title="Deep Learning for Financial Time Series Forecasting", - abstract="This paper proposes a novel LSTM-based approach for predicting stock prices using high-frequency data.", - keywords="deep learning, LSTM, financial forecasting, high-frequency trading", - ) - - print("Generated Prompt:") - print(prompt) - - -def example_relative_path(): - """Test relative path loading.""" - # Test loading from local.yaml in the same directory - local_prompt = T(".prompts:local_prompt").r( - custom_param="test value", local_context="relative path test" - ) - - print("\nRelative Path Test:") - print(local_prompt) - - # Test nested key access - nested_prompt = T(".prompts:nested_test.key1").r(var1="nested value") - - print("\nNested Key Test:") - print(nested_prompt) - - # Test conditional template - conditional_prompt = T(".prompts:conditional_test").r( - condition=True, value="conditional test" - ) - - print("\nConditional Test:") - print(conditional_prompt) - - -if __name__ == "__main__": - example_basic_usage() - example_relative_path() diff --git a/pyproject.toml b/pyproject.toml index 8496e47..2859069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,12 +45,7 @@ dependencies = [ "ruff", "pytest", "numpy>=2.2.4", - "networkx>=3.4.2", - "scikit-learn>=1.6.1", - "pyvis==0.3.1", - "plotly>=6.0.1", "openai>=1.68.2", - "camel-ai>=0.2.42", "pillow>=10.1.0,<11.0.0", "llama-cloud-services>=0.6.12", "ipykernel>=6.29.5", @@ -63,9 +58,6 @@ dependencies = [ "jinja2>=3.0.0", ] -[project.scripts] -quantmind = "quantmind_cli:main" - [project.optional-dependencies] dev = [ "pytest>=7.0.0", diff --git a/quantmind/brain/__init__.py b/quantmind/brain/__init__.py deleted file mode 100644 index 2538acf..0000000 --- a/quantmind/brain/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .memory import Memory - -__all__ = ["Memory"] diff --git a/quantmind/brain/memory.py b/quantmind/brain/memory.py deleted file mode 100644 index b1725f5..0000000 --- a/quantmind/brain/memory.py +++ /dev/null @@ -1,154 +0,0 @@ -import inspect -from logging import getLogger -from typing import Callable, Type - -from quantmind.models.memory import ( - ActionStep, - MemoryStep, - PlanningStep, - SystemPromptStep, - TaskStep, -) -from quantmind.utils.monitoring import AgentLogger, LogLevel - -logger = getLogger(__name__) - - -class Memory: - """Memory for the brain, containing the system prompt and all steps taken by the brain. - - This class is used to store the agent's steps, including tasks, actions, and planning steps. - It allows for resetting the memory, retrieving succinct or full step information, and replaying - the agent's steps. - - Args: - system_prompt (`str`): System prompt for the agent, which sets the context and instructions - for the agent's behavior. - - **Attributes**: - - **system_prompt** (`SystemPromptStep`) -- System prompt step for the agent. - - **steps** (`list[TaskStep | ActionStep | PlanningStep]`) -- List of steps taken by the - agent, which can include tasks, actions, and planning steps. - """ - - def __init__(self, system_prompt: str): - self.system_prompt: SystemPromptStep = SystemPromptStep( - system_prompt=system_prompt - ) - self.steps: list[TaskStep | ActionStep | PlanningStep] = [] - - def reset(self): - """Reset the agent's memory, clearing all steps and keeping the system prompt.""" - self.steps = [] - - def get_succinct_steps(self) -> list[dict]: - """Return a succinct representation of the agent's steps, excluding model input messages.""" - return [ - { - key: value - for key, value in step.dict().items() - if key != "model_input_messages" - } - for step in self.steps - ] - - def get_full_steps(self) -> list[dict]: - """Return a full representation of the agent's steps, including model input messages.""" - if len(self.steps) == 0: - return [] - return [step.dict() for step in self.steps] - - def replay(self, logger: AgentLogger, detailed: bool = False): - """Prints a pretty replay of the agent's steps. - - Args: - logger (`AgentLogger`): The logger to print replay logs to. - detailed (`bool`, default `False`): If True, also displays the memory at each step. - Defaults to False. - Careful: will increase log length exponentially. Use only for debugging. - """ - logger.console.log("Replaying the agent's steps:") - logger.log_markdown( - title="System prompt", - content=self.system_prompt.system_prompt, - level=LogLevel.ERROR, - ) - for step in self.steps: - if isinstance(step, TaskStep): - logger.log_task(step.task, "", level=LogLevel.ERROR) - elif isinstance(step, ActionStep): - logger.log_rule( - f"Step {step.step_number}", level=LogLevel.ERROR - ) - if detailed and step.model_input_messages is not None: - logger.log_messages( - step.model_input_messages, level=LogLevel.ERROR - ) - if step.model_output is not None: - logger.log_markdown( - title="Agent output:", - content=step.model_output, - level=LogLevel.ERROR, - ) - elif isinstance(step, PlanningStep): - logger.log_rule("Planning step", level=LogLevel.ERROR) - if detailed and step.model_input_messages is not None: - logger.log_messages( - step.model_input_messages, level=LogLevel.ERROR - ) - logger.log_markdown( - title="Agent output:", - content=step.plan, - level=LogLevel.ERROR, - ) - - def return_full_code(self) -> str: - """Returns all code actions from the agent's steps, concatenated as a single script.""" - return "\n\n".join( - [ - step.code_action - for step in self.steps - if isinstance(step, ActionStep) and step.code_action is not None - ] - ) - - -class CallbackRegistry: - """Registry for callbacks that are called at each step of the agent's execution. - - Callbacks are registered by passing a step class and a callback function. - """ - - def __init__(self): - self._callbacks: dict[Type[MemoryStep], list[Callable]] = {} - - def register(self, step_cls: Type[MemoryStep], callback: Callable): - """Register a callback for a step class. - - Args: - step_cls (Type[MemoryStep]): Step class to register the callback for. - callback (Callable): Callback function to register. - """ - if step_cls not in self._callbacks: - self._callbacks[step_cls] = [] - self._callbacks[step_cls].append(callback) - - def callback(self, memory_step, **kwargs): - """Call callbacks registered for a step type. - - Args: - memory_step (MemoryStep): Step to call the callbacks for. - **kwargs: Additional arguments to pass to callbacks that accept them. - Typically, includes the agent instance. - - Notes: - For backwards compatibility, callbacks with a single parameter signature - receive only the memory_step, while callbacks with multiple parameters - receive both the memory_step and any additional kwargs. - """ - # For compatibility with old callbacks that only take the step as an argument - for cls in memory_step.__class__.__mro__: - for cb in self._callbacks.get(cls, []): - cb(memory_step) if len( - inspect.signature(cb).parameters - ) == 1 else cb(memory_step, **kwargs) diff --git a/quantmind/models/memory.py b/quantmind/models/memory.py deleted file mode 100644 index 16b16a9..0000000 --- a/quantmind/models/memory.py +++ /dev/null @@ -1,253 +0,0 @@ -from dataclasses import asdict, dataclass -from logging import getLogger -from typing import TYPE_CHECKING, Any - -from quantmind.models.messages import ( - ChatMessage, - MessageRole, - get_dict_from_nested_dataclasses, -) -from quantmind.utils.agentic_ext import AgentError, make_json_serializable -from quantmind.utils.monitoring import Timing, TokenUsage - -if TYPE_CHECKING: - import PIL.Image - - -__all__ = ["AgentMemory"] - - -logger = getLogger(__name__) - - -@dataclass -class ToolCall: - name: str - arguments: Any - id: str - - def dict(self): - return { - "id": self.id, - "type": "function", - "function": { - "name": self.name, - "arguments": make_json_serializable(self.arguments), - }, - } - - -@dataclass -class MemoryStep: - def dict(self): - return asdict(self) - - def to_messages(self, summary_mode: bool = False) -> list[ChatMessage]: - raise NotImplementedError - - -@dataclass -class ActionStep(MemoryStep): - step_number: int - timing: Timing - model_input_messages: list[ChatMessage] | None = None - tool_calls: list[ToolCall] | None = None - error: AgentError | None = None - model_output_message: ChatMessage | None = None - model_output: str | list[dict[str, Any]] | None = None - code_action: str | None = None - observations: str | None = None - observations_images: list["PIL.Image.Image"] | None = None - action_output: Any = None - token_usage: TokenUsage | None = None - is_final_answer: bool = False - - def dict(self): - # We overwrite the method to parse the tool_calls and action_output manually - return { - "step_number": self.step_number, - "timing": self.timing.dict(), - "model_input_messages": [ - make_json_serializable(get_dict_from_nested_dataclasses(msg)) - for msg in self.model_input_messages - ] - if self.model_input_messages - else None, - "tool_calls": [tc.dict() for tc in self.tool_calls] - if self.tool_calls - else [], - "error": self.error.dict() if self.error else None, - "model_output_message": make_json_serializable( - get_dict_from_nested_dataclasses(self.model_output_message) - ) - if self.model_output_message - else None, - "model_output": self.model_output, - "code_action": self.code_action, - "observations": self.observations, - "observations_images": [ - image.tobytes() for image in self.observations_images - ] - if self.observations_images - else None, - "action_output": make_json_serializable(self.action_output), - "token_usage": asdict(self.token_usage) - if self.token_usage - else None, - "is_final_answer": self.is_final_answer, - } - - def to_messages(self, summary_mode: bool = False) -> list[ChatMessage]: - messages = [] - if self.model_output is not None and not summary_mode: - messages.append( - ChatMessage( - role=MessageRole.ASSISTANT, - content=[ - {"type": "text", "text": self.model_output.strip()} - ], - ) - ) - - if self.tool_calls is not None: - messages.append( - ChatMessage( - role=MessageRole.TOOL_CALL, - content=[ - { - "type": "text", - "text": "Calling tools:\n" - + str([tc.dict() for tc in self.tool_calls]), - } - ], - ) - ) - - if self.observations_images: - messages.append( - ChatMessage( - role=MessageRole.USER, - content=[ - { - "type": "image", - "image": image, - } - for image in self.observations_images - ], - ) - ) - - if self.observations is not None: - messages.append( - ChatMessage( - role=MessageRole.TOOL_RESPONSE, - content=[ - { - "type": "text", - "text": f"Observation:\n{self.observations}", - } - ], - ) - ) - if self.error is not None: - error_message = ( - "Error:\n" - + str(self.error) - + "\nNow let's retry: take care not to repeat previous errors! If you have retried several times, try a completely different approach.\n" - ) - message_content = ( - f"Call id: {self.tool_calls[0].id}\n" if self.tool_calls else "" - ) - message_content += error_message - messages.append( - ChatMessage( - role=MessageRole.TOOL_RESPONSE, - content=[{"type": "text", "text": message_content}], - ) - ) - - return messages - - -@dataclass -class PlanningStep(MemoryStep): - model_input_messages: list[ChatMessage] - model_output_message: ChatMessage - plan: str - timing: Timing - token_usage: TokenUsage | None = None - - def dict(self): - return { - "model_input_messages": [ - make_json_serializable(get_dict_from_nested_dataclasses(msg)) - for msg in self.model_input_messages - ], - "model_output_message": make_json_serializable( - get_dict_from_nested_dataclasses(self.model_output_message) - ), - "plan": self.plan, - "timing": self.timing.dict(), - "token_usage": asdict(self.token_usage) - if self.token_usage - else None, - } - - def to_messages(self, summary_mode: bool = False) -> list[ChatMessage]: - if summary_mode: - return [] - return [ - ChatMessage( - role=MessageRole.ASSISTANT, - content=[{"type": "text", "text": self.plan.strip()}], - ), - ChatMessage( - role=MessageRole.USER, - content=[ - { - "type": "text", - "text": "Now proceed and carry out this plan.", - } - ], - ), - # This second message creates a role change to prevent models models - # from simply continuing the plan message - ] - - -@dataclass -class TaskStep(MemoryStep): - task: str - task_images: list["PIL.Image.Image"] | None = None - - def to_messages(self, summary_mode: bool = False) -> list[ChatMessage]: - content = [{"type": "text", "text": f"New task:\n{self.task}"}] - if self.task_images: - content.extend( - [ - {"type": "image", "image": image} - for image in self.task_images - ] - ) - - return [ChatMessage(role=MessageRole.USER, content=content)] - - -@dataclass -class SystemPromptStep(MemoryStep): - system_prompt: str - - def to_messages(self, summary_mode: bool = False) -> list[ChatMessage]: - if summary_mode: - return [] - return [ - ChatMessage( - role=MessageRole.SYSTEM, - content=[{"type": "text", "text": self.system_prompt}], - ) - ] - - -@dataclass -class FinalAnswerStep(MemoryStep): - output: Any diff --git a/quantmind/models/messages.py b/quantmind/models/messages.py deleted file mode 100644 index fb48b89..0000000 --- a/quantmind/models/messages.py +++ /dev/null @@ -1,447 +0,0 @@ -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import json -import logging -import re -import uuid -from copy import deepcopy -from dataclasses import asdict, dataclass -from enum import Enum -from typing import Any - -from quantmind.tools import Tool -from quantmind.utils.agentic_ext import ( - encode_image_base64, - make_image_url, - parse_json_blob, -) -from quantmind.utils.monitoring import TokenUsage - -logger = logging.getLogger(__name__) - -STRUCTURED_GENERATION_PROVIDERS = ["cerebras", "fireworks-ai"] - - -def get_dict_from_nested_dataclasses(obj, ignore_key=None): - """Convert a nested dataclass to a dictionary.""" - - def convert(obj): - if hasattr(obj, "__dataclass_fields__"): - return { - k: convert(v) for k, v in asdict(obj).items() if k != ignore_key - } - return obj - - return convert(obj) - - -@dataclass -class ChatMessageToolCallFunction: - """Function for a tool call.""" - - arguments: Any - name: str - description: str | None = None - - -@dataclass -class ChatMessageToolCall: - """Tool call for a chat message.""" - - function: ChatMessageToolCallFunction - id: str - type: str - - def __str__(self) -> str: - return f"Call: {self.id}: Calling {str(self.function.name)} with arguments: {str(self.function.arguments)}" - - -class MessageRole(str, Enum): - """Message role.""" - - USER = "user" - ASSISTANT = "assistant" - SYSTEM = "system" - TOOL_CALL = "tool-call" - TOOL_RESPONSE = "tool-response" - - @classmethod - def roles(cls): - return [r.value for r in cls] - - -@dataclass -class ChatMessage: - """Chat message.""" - - role: MessageRole - content: str | list[dict[str, Any]] | None = None - tool_calls: list[ChatMessageToolCall] | None = None - raw: Any | None = None # Stores the raw output from the API - token_usage: TokenUsage | None = None - - def model_dump_json(self): - return json.dumps( - get_dict_from_nested_dataclasses(self, ignore_key="raw") - ) - - @classmethod - def from_dict( - cls, - data: dict, - raw: Any | None = None, - token_usage: TokenUsage | None = None, - ) -> "ChatMessage": - if data.get("tool_calls"): - tool_calls = [ - ChatMessageToolCall( - function=ChatMessageToolCallFunction(**tc["function"]), - id=tc["id"], - type=tc["type"], - ) - for tc in data["tool_calls"] - ] - data["tool_calls"] = tool_calls - return cls( - role=data["role"], - content=data.get("content"), - tool_calls=data.get("tool_calls"), - raw=raw, - token_usage=token_usage, - ) - - def dict(self): - return get_dict_from_nested_dataclasses(self) - - def render_as_markdown(self) -> str: - rendered = str(self.content) or "" - if self.tool_calls: - rendered += "\n".join( - [ - json.dumps( - { - "tool": tool.function.name, - "arguments": tool.function.arguments, - } - ) - for tool in self.tool_calls - ] - ) - return rendered - - -def parse_json_if_needed(arguments: str | dict) -> str | dict: - """Parse a JSON string if needed.""" - if isinstance(arguments, dict): - return arguments - else: - try: - return json.loads(arguments) - except Exception: - return arguments - - -@dataclass -class ChatMessageToolCallStreamDelta: - """Represents a streaming delta for tool calls during generation.""" - - index: int | None = None - id: str | None = None - type: str | None = None - function: ChatMessageToolCallFunction | None = None - - -@dataclass -class ChatMessageStreamDelta: - """Represents a streaming delta for a chat message.""" - - content: str | None = None - tool_calls: list[ChatMessageToolCallStreamDelta] | None = None - token_usage: TokenUsage | None = None - - -def agglomerate_stream_deltas( - stream_deltas: list[ChatMessageStreamDelta], - role: MessageRole = MessageRole.ASSISTANT, -) -> ChatMessage: - """Agglomerate a list of stream deltas into a single stream delta. - - Args: - stream_deltas (`list[ChatMessageStreamDelta]`): List of chat message stream deltas. - role (`MessageRole`, *optional*): Role of the chat message. - - Returns: - `ChatMessage`: Agglomerated chat message. - """ - accumulated_tool_calls: dict[int, ChatMessageToolCallStreamDelta] = {} - accumulated_content = "" - total_input_tokens = 0 - total_output_tokens = 0 - for stream_delta in stream_deltas: - if stream_delta.token_usage: - total_input_tokens += stream_delta.token_usage.input_tokens - total_output_tokens += stream_delta.token_usage.output_tokens - if stream_delta.content: - accumulated_content += stream_delta.content - if stream_delta.tool_calls: - for tool_call_delta in ( - stream_delta.tool_calls - ): # ?ormally there should be only one call at a time - # Extend accumulated_tool_calls list to accommodate the new tool call if needed - if tool_call_delta.index is not None: - if tool_call_delta.index not in accumulated_tool_calls: - accumulated_tool_calls[tool_call_delta.index] = ( - ChatMessageToolCallStreamDelta( - id=tool_call_delta.id, - type=tool_call_delta.type, - function=ChatMessageToolCallFunction( - name="", arguments="" - ), - ) - ) - # Update the tool call at the specific index - tool_call = accumulated_tool_calls[tool_call_delta.index] - if tool_call_delta.id: - tool_call.id = tool_call_delta.id - if tool_call_delta.type: - tool_call.type = tool_call_delta.type - if tool_call_delta.function: - if ( - tool_call_delta.function.name - and len(tool_call_delta.function.name) > 0 - ): - tool_call.function.name = ( - tool_call_delta.function.name - ) - if tool_call_delta.function.arguments: - tool_call.function.arguments += ( - tool_call_delta.function.arguments - ) - else: - raise ValueError( - f"Tool call index is not provided in tool delta: {tool_call_delta}" - ) - - return ChatMessage( - role=role, - content=accumulated_content, - tool_calls=[ - ChatMessageToolCall( - function=ChatMessageToolCallFunction( - name=tool_call_stream_delta.function.name, - arguments=tool_call_stream_delta.function.arguments, - ), - id=tool_call_stream_delta.id or "", - type="function", - ) - for tool_call_stream_delta in accumulated_tool_calls.values() - if tool_call_stream_delta.function - ], - token_usage=TokenUsage( - input_tokens=total_input_tokens, - output_tokens=total_output_tokens, - ), - ) - - -tool_role_conversions = { - MessageRole.TOOL_CALL: MessageRole.ASSISTANT, - MessageRole.TOOL_RESPONSE: MessageRole.USER, -} - - -def get_tool_json_schema(tool: Tool) -> dict: - """Get a JSON schema for a tool.""" - properties = deepcopy(tool.inputs) - required = [] - for key, value in properties.items(): - if value["type"] == "any": - value["type"] = "string" - if not ("nullable" in value and value["nullable"]): - required.append(key) - return { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": { - "type": "object", - "properties": properties, - "required": required, - }, - }, - } - - -def remove_stop_sequences(content: str, stop_sequences: list[str]) -> str: - """Remove stop sequences from a content.""" - for stop_seq in stop_sequences: - if content[-len(stop_seq) :] == stop_seq: - content = content[: -len(stop_seq)] - return content - - -def get_clean_message_list( - message_list: list[ChatMessage | dict], - role_conversions: dict[MessageRole, MessageRole] | dict[str, str] = {}, - convert_images_to_image_urls: bool = False, - flatten_messages_as_text: bool = False, -) -> list[dict[str, Any]]: - """Get a clean message list. - - Creates a list of messages to give as input to the LLM. - These messages are dictionaries and chat - template compatible with transformers LLM chat template. - Subsequent messages with the same role will be concatenated to a single message. - - Args: - message_list (`list[ChatMessage | dict]`): List of chat messages. Mixed types are allowed. - role_conversions (`dict[MessageRole, MessageRole]`, *optional* ): Mapping to convert roles. - convert_images_to_image_urls (`bool`, default `False`): - Whether to convert images to imageURLs. - flatten_messages_as_text (`bool`, default `False`): Whether to flatten messages as text. - """ - output_message_list: list[dict[str, Any]] = [] - message_list = deepcopy(message_list) # Avoid modifying the original list - for message in message_list: - if isinstance(message, dict): - message = ChatMessage.from_dict(message) - role = message.role - if role not in MessageRole.roles(): - raise ValueError( - f"Incorrect role {role}, only {MessageRole.roles()} are supported for now." - ) - - if role in role_conversions: - message.role = role_conversions[role] # type: ignore - # encode images if needed - if isinstance(message.content, list): - for element in message.content: - assert isinstance(element, dict), ( - "Error: this element should be a dict:" + str(element) - ) - if element["type"] == "image": - assert ( - not flatten_messages_as_text - ), f"Cannot use images with {flatten_messages_as_text=}" - if convert_images_to_image_urls: - element.update( - { - "type": "image_url", - "image_url": { - "url": make_image_url( - encode_image_base64( - element.pop("image") - ) - ) - }, - } - ) - else: - element["image"] = encode_image_base64(element["image"]) - - if ( - len(output_message_list) > 0 - and message.role == output_message_list[-1]["role"] - ): - assert isinstance(message.content, list), ( - "Error: wrong content:" + str(message.content) - ) - if flatten_messages_as_text: - output_message_list[-1]["content"] += ( - "\n" + message.content[0]["text"] - ) - else: - for el in message.content: - if ( - el["type"] == "text" - and output_message_list[-1]["content"][-1]["type"] - == "text" - ): - # Merge consecutive text messages rather than creating new ones - output_message_list[-1]["content"][-1]["text"] += ( - "\n" + el["text"] - ) - else: - output_message_list[-1]["content"].append(el) - else: - if flatten_messages_as_text: - content = message.content[0]["text"] - else: - content = message.content - output_message_list.append( - { - "role": message.role, - "content": content, - } - ) - return output_message_list - - -def get_tool_call_from_text( - text: str, tool_name_key: str, tool_arguments_key: str -) -> ChatMessageToolCall: - """Get a tool call from a text.""" - tool_call_dictionary, _ = parse_json_blob(text) - try: - tool_name = tool_call_dictionary[tool_name_key] - except Exception as e: - raise ValueError( - f"Tool call needs to have a key '{tool_name_key}'. Got keys: {list(tool_call_dictionary.keys())} instead" - ) from e - tool_arguments = tool_call_dictionary.get(tool_arguments_key, None) - if isinstance(tool_arguments, str): - tool_arguments = parse_json_if_needed(tool_arguments) - return ChatMessageToolCall( - id=str(uuid.uuid4()), - type="function", - function=ChatMessageToolCallFunction( - name=tool_name, arguments=tool_arguments - ), - ) - - -def supports_stop_parameter(model_id: str) -> bool: - """Check if the model supports the `stop` parameter. - - Not supported with reasoning models openai/o3, openai/o4-mini, and - the openai/gpt-5 series (and their versioned variants). - - Args: - model_id (`str`): Model identifier (e.g. "openai/o3", "o4-mini-2025-04-16") - - Returns: - bool: True if the model supports the stop parameter, False otherwise - """ - model_name = model_id.split("/")[-1] - # o3, o4-mini, grok-3-mini, grok-4, grok-code-fast and the gpt-5 series - # (including versioned variants, o3-2025-04-16) don't support stop parameter - openai_model_pattern = r"(o3[-\d]*|o4-mini[-\d]*|gpt-5(-mini|-nano)?[-\d]*)" - grok_model_pattern = ( - r"([a-zA-Z]+\.)?(grok-3-mini|grok-4|grok-code-fast)(-[A-Za-z0-9]*)?" - ) - pattern = rf"^({openai_model_pattern}|{grok_model_pattern})$" - - return not re.match(pattern, model_name) - - -class _ParameterRemove: - """Sentinel value to indicate a parameter should be removed.""" - - def __repr__(self): - return "REMOVE_PARAMETER" - - -# Singleton instance for removing parameters -REMOVE_PARAMETER = _ParameterRemove() diff --git a/quantmind/storage/__init__.py b/quantmind/storage/__init__.py deleted file mode 100644 index 8f7e057..0000000 --- a/quantmind/storage/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Storage systems for QuantMind knowledge base.""" - -from quantmind.storage.base import BaseStorage -from quantmind.storage.local_storage import LocalStorage - -__all__ = ["BaseStorage", "LocalStorage"] diff --git a/quantmind/storage/base.py b/quantmind/storage/base.py deleted file mode 100644 index 4f77d12..0000000 --- a/quantmind/storage/base.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Base storage interface for QuantMind knowledge base.""" - -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional - -from quantmind.models import KnowledgeItem, Paper -from quantmind.utils.logger import get_logger - -logger = get_logger(__name__) - - -class BaseStorage(ABC): - """Abstract base class for knowledge storage backends. - - Manages four types of data: - - Raw Files: PDFs, markdown files, etc. - - Knowledges: KnowledgeItem objects as JSON - - Embeddings: Embedding vectors as arrays - - Extra: Additional metadata and hashes - """ - - # Raw Files Management - @abstractmethod - def store_raw_file( - self, - file_id: str, - file_path: Optional[Path] = None, - content: Optional[bytes] = None, - file_extension: str = "", - ) -> str: - """Store a raw file (PDF, markdown, etc.). - - Args: - file_id: Unique identifier for the file - file_path: Path to existing file to copy (mutually exclusive with content) - content: Raw bytes content to write directly (mutually exclusive with file_path) - file_extension: File extension when using content (e.g., '.pdf', '.txt') - - Returns: - Path to stored file - - Raises: - ValueError: If both file_path and content are provided or both are None - """ - pass - - @abstractmethod - def get_raw_file(self, file_id: str) -> Optional[Path]: - """Get path to a raw file.""" - pass - - @abstractmethod - def delete_raw_file(self, file_id: str) -> bool: - """Delete a raw file.""" - pass - - # Knowledge Items Management - @abstractmethod - def store_knowledge(self, knowledge: KnowledgeItem) -> str: - """Store a knowledge item.""" - pass - - @abstractmethod - def get_knowledge(self, knowledge_id: str) -> Optional[KnowledgeItem]: - """Get a knowledge item by ID.""" - pass - - @abstractmethod - def delete_knowledge(self, knowledge_id: str) -> bool: - """Delete a knowledge item.""" - pass - - # Embeddings Management - @abstractmethod - def store_embedding( - self, knowledge_id: str, embedding: List[float], model: str - ) -> str: - """Store an embedding vector.""" - pass - - @abstractmethod - def get_embedding(self, knowledge_id: str) -> Optional[Dict[str, Any]]: - """Get embedding data for a knowledge item.""" - pass - - @abstractmethod - def delete_embedding(self, knowledge_id: str) -> bool: - """Delete an embedding.""" - pass - - # Extra Data Management - @abstractmethod - def store_extra(self, key: str, data: Any) -> str: - """Store extra data (hashes, metadata, etc.).""" - pass - - @abstractmethod - def get_extra(self, key: str) -> Optional[Any]: - """Get extra data by key.""" - pass - - @abstractmethod - def delete_extra(self, key: str) -> bool: - """Delete extra data.""" - pass - - # Specialized Knowledge Item Processing - def process_knowledge(self, knowledge: KnowledgeItem) -> str: - """Store knowledge item with specialized processing based on type. - - This method provides type-specific handling: - - Paper: Download PDF if URL available and not already stored - - Other types: Basic storage - - Args: - knowledge: KnowledgeItem instance to store - - Returns: - Knowledge ID after storage - """ - knowledge_id = knowledge.get_primary_id() - - # Store the knowledge item first - stored_id = self.store_knowledge(knowledge) - - # Type-specific processing - if isinstance(knowledge, Paper): - logger.info( - f"Storage Processing paper {knowledge.get_primary_id()}" - ) - self._handle_paper_files(knowledge) - - return stored_id - - def process_knowledges(self, knowledges: List[KnowledgeItem]) -> List[str]: - """Process a list of knowledge items.""" - return [self.process_knowledge(knowledge) for knowledge in knowledges] - - def _handle_paper_files(self, paper: Paper) -> None: - """Handle file operations for Paper objects. - - Args: - paper: Paper instance to process - """ - paper_id = paper.get_primary_id() - - # Check if PDF file already exists - existing_pdf = self.get_raw_file(paper_id) - if existing_pdf and existing_pdf.exists(): - return # File already exists - - # Try to download PDF if URL is available - if paper.pdf_url: - try: - content = self._download_file_content(paper.pdf_url) - if content: - self.store_raw_file( - file_id=paper_id, content=content, file_extension=".pdf" - ) - except Exception as e: - # Log error but don't fail the entire operation - logger.error( - f"Failed to download PDF for {paper.get_primary_id()}: {e}" - ) - - def _download_file_content( - self, url: str, timeout: Optional[int] = None - ) -> Optional[bytes]: - """Download file content from URL. - - Args: - url: URL to download from - timeout: Timeout in seconds (uses config if None) - - Returns: - File content as bytes or None if failed - """ - # Use config timeout if not provided - if timeout is None: - timeout = getattr(self, "config", None) - timeout = ( - getattr(timeout, "download_timeout", 30) if timeout else 30 - ) - - try: - import requests - - response = requests.get(url, timeout=timeout) - response.raise_for_status() - return response.content - except Exception: - return None - - # Utility Methods - def get_all_knowledges(self) -> Iterator[KnowledgeItem]: - """Get all knowledge items.""" - return iter(self.search_knowledges(limit=None)) - - def knowledge_exists(self, knowledge_id: str) -> bool: - """Check if a knowledge item exists.""" - return self.get_knowledge(knowledge_id) is not None - - def get_storage_info(self) -> Dict[str, Any]: - """Get storage information.""" - return { - "type": self.__class__.__name__, - "knowledge_count": len(list(self.get_all_knowledges())), - } - - def __str__(self) -> str: - """String representation.""" - return f"{self.__class__.__name__}()" diff --git a/quantmind/storage/local_storage.py b/quantmind/storage/local_storage.py deleted file mode 100644 index 0f72f8f..0000000 --- a/quantmind/storage/local_storage.py +++ /dev/null @@ -1,558 +0,0 @@ -"""Local file-based storage implementation for QuantMind.""" - -import json -import shutil -from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional - -from quantmind.config import LocalStorageConfig -from quantmind.models import KnowledgeItem -from quantmind.utils.logger import get_logger - -from .base import BaseStorage - -logger = get_logger(__name__) - - -class LocalStorage(BaseStorage): - """Local file-based storage implementation. - - Organizes data into four directories: - - raw_files/: Original files (PDFs, markdown, etc.) - - knowledges/: KnowledgeItem objects as JSON - - embeddings/: Embedding vectors as JSON arrays - - extra/: Additional metadata and hashes - - Uses efficient indexing system for fast lookups: - - raw_files_index.json: file_id -> {"path": "xxx", "extension": "yyy"} - - knowledges_index.json: knowledge_id -> {"path": "xxx"} - - embeddings_index.json: knowledge_id -> {"path": "xxx"} - """ - - def __init__(self, config: LocalStorageConfig): - """Initialize local storage. - - Args: - config: LocalStorageConfig instance - """ - self.config = config - self.config.model_post_init(None) # Ensure directories exist - - # Initialize indexes - self._raw_files_index: Dict[str, Dict[str, str]] = {} - self._knowledges_index: Dict[str, Dict[str, str]] = {} - self._embeddings_index: Dict[str, Dict[str, str]] = {} - - self._load_indexes() - logger.info(f"LocalStorage initialized at {self.config.storage_dir}") - - def _get_index_path(self, index_type: str) -> Path: - """Get path to index file.""" - return self.config.extra_dir / f"{index_type}_index.json" - - def _load_indexes(self) -> None: - """Load all indexes from disk.""" - self._load_index("raw_files") - self._load_index("knowledges") - self._load_index("embeddings") - - def _load_index(self, index_type: str) -> None: - """Load specific index from disk.""" - index_path = self._get_index_path(index_type) - try: - if index_path.exists(): - with open(index_path, "r", encoding="utf-8") as f: - index_data = json.load(f) - - if index_type == "raw_files": - self._raw_files_index = index_data - elif index_type == "knowledges": - self._knowledges_index = index_data - elif index_type == "embeddings": - self._embeddings_index = index_data - else: - # Build index from existing files if index doesn't exist - self._rebuild_index(index_type) - - except Exception as e: - logger.warning( - f"Failed to load {index_type} index: {e}, rebuilding..." - ) - self._rebuild_index(index_type) - - def _save_index(self, index_type: str) -> None: - """Save specific index to disk.""" - index_path = self._get_index_path(index_type) - try: - if index_type == "raw_files": - index_data = self._raw_files_index - elif index_type == "knowledges": - index_data = self._knowledges_index - elif index_type == "embeddings": - index_data = self._embeddings_index - else: - return - - with open(index_path, "w", encoding="utf-8") as f: - json.dump(index_data, f, indent=2, ensure_ascii=False) - - except Exception as e: - logger.error(f"Failed to save {index_type} index: {e}") - - def _rebuild_index(self, index_type: str) -> None: - """Rebuild index by scanning directory.""" - logger.info(f"Rebuilding {index_type} index...") - - if index_type == "raw_files": - self._raw_files_index.clear() - if self.config.raw_files_dir.exists(): - for file_path in self.config.raw_files_dir.iterdir(): - if file_path.is_file(): - # Extract file_id from filename (everything before last dot) - file_id = file_path.stem - self._raw_files_index[file_id] = { - "path": str( - file_path.relative_to(self.config.storage_dir) - ), - "extension": file_path.suffix, - } - - elif index_type == "knowledges": - self._knowledges_index.clear() - if self.config.knowledges_dir.exists(): - for file_path in self.config.knowledges_dir.glob("*.json"): - knowledge_id = file_path.stem - self._knowledges_index[knowledge_id] = { - "path": str( - file_path.relative_to(self.config.storage_dir) - ) - } - - elif index_type == "embeddings": - self._embeddings_index.clear() - if self.config.embeddings_dir.exists(): - for file_path in self.config.embeddings_dir.glob("*.json"): - knowledge_id = file_path.stem - self._embeddings_index[knowledge_id] = { - "path": str( - file_path.relative_to(self.config.storage_dir) - ) - } - - self._save_index(index_type) - logger.info( - f"Rebuilt {index_type} index with {len(getattr(self, f'_{index_type}_index'))} entries" - ) - - def rebuild_all_indexes(self) -> None: - """Rebuild all indexes from scratch.""" - logger.info("Rebuilding all indexes...") - self._rebuild_index("raw_files") - self._rebuild_index("knowledges") - self._rebuild_index("embeddings") - logger.info("All indexes rebuilt successfully") - - # Raw Files Management - def store_raw_file( - self, - file_id: str, - file_path: Optional[Path] = None, - content: Optional[bytes] = None, - file_extension: str = "", - ) -> str: - """Store a raw file by copying or writing content directly.""" - try: - # Validate input parameters - if file_path is not None and content is not None: - raise ValueError("Cannot specify both file_path and content") - if file_path is None and content is None: - raise ValueError("Must specify either file_path or content") - - # Determine target path and extension - if file_path is not None: - # Copy from existing file - source_path = Path(file_path) - if not source_path.exists(): - raise FileNotFoundError( - f"Source file not found: {file_path}" - ) - - target_path = ( - self.config.raw_files_dir / f"{file_id}{source_path.suffix}" - ) - extension = source_path.suffix - - # Copy file - shutil.copy2(source_path, target_path) - logger.debug( - f"Stored raw file {file_id} by copying from {file_path}" - ) - - else: - # Write content directly - if not file_extension: - file_extension = ".bin" # Default extension - if not file_extension.startswith("."): - file_extension = f".{file_extension}" - - target_path = ( - self.config.raw_files_dir / f"{file_id}{file_extension}" - ) - extension = file_extension - - # Write content to file - with open(target_path, "wb") as f: - f.write(content) - logger.debug( - f"Stored raw file {file_id} by writing content directly" - ) - - # Update index - self._raw_files_index[file_id] = { - "path": str(target_path.relative_to(self.config.storage_dir)), - "extension": extension, - } - self._save_index("raw_files") - - return str(target_path) - - except Exception as e: - logger.error(f"Failed to store raw file {file_id}: {e}") - raise - - def get_raw_file(self, file_id: str) -> Optional[Path]: - """Get path to a raw file using efficient index lookup.""" - try: - # Fast index lookup - if file_id in self._raw_files_index: - relative_path = self._raw_files_index[file_id]["path"] - file_path = self.config.storage_dir / relative_path - - if file_path.exists(): - return file_path - else: - # File was deleted externally, remove from index - logger.warning( - f"Raw file {file_id} in index but missing on disk, removing from index" - ) - del self._raw_files_index[file_id] - self._save_index("raw_files") - return None - - # Fallback to directory scan if not in index - for file_path in self.config.raw_files_dir.glob(f"{file_id}.*"): - if file_path.is_file(): - # Add to index for future lookups - self._raw_files_index[file_id] = { - "path": str( - file_path.relative_to(self.config.storage_dir) - ), - "extension": file_path.suffix, - } - self._save_index("raw_files") - return file_path - - return None - - except Exception as e: - logger.error(f"Failed to get raw file {file_id}: {e}") - return None - - def delete_raw_file(self, file_id: str) -> bool: - """Delete a raw file and update index.""" - try: - file_path = self.get_raw_file(file_id) - if file_path and file_path.exists(): - file_path.unlink() - - # Remove from index - if file_id in self._raw_files_index: - del self._raw_files_index[file_id] - self._save_index("raw_files") - - logger.debug(f"Deleted raw file {file_id}") - return True - return False - - except Exception as e: - logger.error(f"Failed to delete raw file {file_id}: {e}") - return False - - # Knowledge Items Management - def store_knowledge(self, knowledge: KnowledgeItem) -> str: - """Store a knowledge item as JSON and update index.""" - try: - knowledge_id = knowledge.get_primary_id() - file_path = self.config.knowledges_dir / f"{knowledge_id}.json" - - # Save to JSON file - with open(file_path, "w", encoding="utf-8") as f: - json.dump( - knowledge.model_dump(), - f, - indent=2, - ensure_ascii=False, - default=str, - ) - - # Update index - self._knowledges_index[knowledge_id] = { - "path": str(file_path.relative_to(self.config.storage_dir)) - } - self._save_index("knowledges") - - logger.debug(f"Stored knowledge {knowledge_id} at {file_path}") - return knowledge_id - - except Exception as e: - logger.error( - f"Failed to store knowledge {knowledge.get_primary_id()}: {e}" - ) - raise - - def get_knowledge_path(self, knowledge_id: str) -> Optional[Path]: - """Get the path to a knowledge item by ID using efficient index lookup.""" - if knowledge_id in self._knowledges_index: - relative_path = self._knowledges_index[knowledge_id]["path"] - return self.config.storage_dir / relative_path - return None - - def get_knowledge(self, knowledge_id: str) -> Optional[KnowledgeItem]: - """Get a knowledge item by ID using efficient index lookup.""" - try: - # Fast index lookup - if knowledge_id in self._knowledges_index: - relative_path = self._knowledges_index[knowledge_id]["path"] - file_path = self.config.storage_dir / relative_path - - if file_path.exists(): - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - return KnowledgeItem(**data) - else: - # File was deleted externally, remove from index - logger.warning( - f"Knowledge {knowledge_id} in index but missing on disk, removing from index" - ) - del self._knowledges_index[knowledge_id] - self._save_index("knowledges") - return None - - # Fallback to direct file check - file_path = self.config.knowledges_dir / f"{knowledge_id}.json" - if file_path.exists(): - # Add to index for future lookups - self._knowledges_index[knowledge_id] = { - "path": str(file_path.relative_to(self.config.storage_dir)) - } - self._save_index("knowledges") - - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - return KnowledgeItem(**data) - - return None - - except Exception as e: - logger.error(f"Failed to get knowledge {knowledge_id}: {e}") - return None - - def delete_knowledge(self, knowledge_id: str) -> bool: - """Delete a knowledge item and update index.""" - try: - # Check index first for fast lookup - if knowledge_id in self._knowledges_index: - relative_path = self._knowledges_index[knowledge_id]["path"] - file_path = self.config.storage_dir / relative_path - else: - file_path = self.config.knowledges_dir / f"{knowledge_id}.json" - - if file_path.exists(): - file_path.unlink() - - # Remove from index - if knowledge_id in self._knowledges_index: - del self._knowledges_index[knowledge_id] - self._save_index("knowledges") - - logger.debug(f"Deleted knowledge {knowledge_id}") - return True - return False - - except Exception as e: - logger.error(f"Failed to delete knowledge {knowledge_id}: {e}") - return False - - # Embeddings Management - def store_embedding( - self, knowledge_id: str, embedding: List[float], model: str - ) -> str: - """Store an embedding vector and update index.""" - try: - file_path = self.config.embeddings_dir / f"{knowledge_id}.json" - - embedding_data = { - "knowledge_id": knowledge_id, - "embedding": embedding, - "model": model, - "created_at": str(Path().stat().st_mtime), # Simple timestamp - } - - with open(file_path, "w", encoding="utf-8") as f: - json.dump(embedding_data, f, indent=2) - - # Update index - self._embeddings_index[knowledge_id] = { - "path": str(file_path.relative_to(self.config.storage_dir)) - } - self._save_index("embeddings") - - logger.debug( - f"Stored embedding for {knowledge_id} (model: {model})" - ) - return knowledge_id - - except Exception as e: - logger.error(f"Failed to store embedding for {knowledge_id}: {e}") - raise - - def get_embedding(self, knowledge_id: str) -> Optional[Dict[str, Any]]: - """Get embedding data for a knowledge item using efficient index lookup.""" - try: - # Fast index lookup - if knowledge_id in self._embeddings_index: - relative_path = self._embeddings_index[knowledge_id]["path"] - file_path = self.config.storage_dir / relative_path - - if file_path.exists(): - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - else: - # File was deleted externally, remove from index - logger.warning( - f"Embedding {knowledge_id} in index but missing on disk, removing from index" - ) - del self._embeddings_index[knowledge_id] - self._save_index("embeddings") - return None - - # Fallback to direct file check - file_path = self.config.embeddings_dir / f"{knowledge_id}.json" - if file_path.exists(): - # Add to index for future lookups - self._embeddings_index[knowledge_id] = { - "path": str(file_path.relative_to(self.config.storage_dir)) - } - self._save_index("embeddings") - - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - - return None - - except Exception as e: - logger.error(f"Failed to get embedding for {knowledge_id}: {e}") - return None - - def delete_embedding(self, knowledge_id: str) -> bool: - """Delete an embedding and update index.""" - try: - # Check index first for fast lookup - if knowledge_id in self._embeddings_index: - relative_path = self._embeddings_index[knowledge_id]["path"] - file_path = self.config.storage_dir / relative_path - else: - file_path = self.config.embeddings_dir / f"{knowledge_id}.json" - - if file_path.exists(): - file_path.unlink() - - # Remove from index - if knowledge_id in self._embeddings_index: - del self._embeddings_index[knowledge_id] - self._save_index("embeddings") - - logger.debug(f"Deleted embedding for {knowledge_id}") - return True - return False - - except Exception as e: - logger.error(f"Failed to delete embedding for {knowledge_id}: {e}") - return False - - # Extra Data Management - def store_extra(self, key: str, data: Any) -> str: - """Store extra data (hashes, metadata, etc.).""" - try: - file_path = self.config.extra_dir / f"{key}.json" - - with open(file_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False, default=str) - - logger.debug(f"Stored extra data for key: {key}") - return key - - except Exception as e: - logger.error(f"Failed to store extra data for {key}: {e}") - raise - - def get_extra(self, key: str) -> Optional[Any]: - """Get extra data by key.""" - try: - file_path = self.config.extra_dir / f"{key}.json" - if not file_path.exists(): - return None - - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - - except Exception as e: - logger.error(f"Failed to get extra data for {key}: {e}") - return None - - def delete_extra(self, key: str) -> bool: - """Delete extra data.""" - try: - file_path = self.config.extra_dir / f"{key}.json" - if file_path.exists(): - file_path.unlink() - logger.debug(f"Deleted extra data for key: {key}") - return True - return False - - except Exception as e: - logger.error(f"Failed to delete extra data for {key}: {e}") - return False - - # Utility Methods - def get_all_knowledges(self) -> Iterator[KnowledgeItem]: - """Get all knowledge items using efficient index.""" - for knowledge_id in self._knowledges_index.keys(): - knowledge = self.get_knowledge(knowledge_id) - if knowledge: - yield knowledge - - def get_storage_info(self) -> Dict[str, Any]: - """Get storage information including index statistics.""" - return { - "type": self.__class__.__name__, - "config": self.config.model_dump(), - "storage_dir": str(self.config.storage_dir), - "knowledge_count": len(self._knowledges_index), - "raw_files_count": len(self._raw_files_index), - "embeddings_count": len(self._embeddings_index), - "indexes": { - "raw_files": { - "entries": len(self._raw_files_index), - "index_file": str(self._get_index_path("raw_files")), - }, - "knowledges": { - "entries": len(self._knowledges_index), - "index_file": str(self._get_index_path("knowledges")), - }, - "embeddings": { - "entries": len(self._embeddings_index), - "index_file": str(self._get_index_path("embeddings")), - }, - }, - } diff --git a/quantmind/tagger/__init__.py b/quantmind/tagger/__init__.py deleted file mode 100644 index ae42963..0000000 --- a/quantmind/tagger/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Content tagging and classification components.""" - -from quantmind.tagger.base import BaseTagger -from quantmind.tagger.llm_tagger import LLMTagger - -__all__ = ["BaseTagger", "LLMTagger"] diff --git a/quantmind/tagger/base.py b/quantmind/tagger/base.py deleted file mode 100644 index 3b8a5c2..0000000 --- a/quantmind/tagger/base.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Base tagger interface for content classification.""" - -from abc import ABC, abstractmethod -from typing import Dict, List, Any, Optional - -from quantmind.models.paper import Paper - - -class BaseTagger(ABC): - """Abstract base class for content tagging and classification. - - Defines the interface for extracting tags, categories, and classifications - from research papers and other content. - """ - - def __init__(self, config: Optional[Dict[str, Any]] = None): - """Initialize tagger with configuration. - - Args: - config: Tagger-specific configuration - """ - self.config = config or {} - self.name = self.__class__.__name__.lower().replace("tagger", "") - - @abstractmethod - def tag_paper(self, paper: Paper) -> Paper: - """Add tags and categories to a paper. - - Args: - paper: Paper object to tag - - Returns: - Paper object with added tags and categories - """ - pass - - def tag_papers(self, papers: List[Paper]) -> List[Paper]: - """Tag multiple papers. - - Args: - papers: List of Paper objects to tag - - Returns: - List of tagged Paper objects - """ - return [self.tag_paper(paper) for paper in papers] - - @abstractmethod - def extract_tags(self, text: str, title: str = "") -> List[str]: - """Extract tags from text content. - - Args: - text: Text content to analyze - title: Optional title for additional context - - Returns: - List of tag strings - """ - pass - - def validate_tags(self, tags: List[str]) -> List[str]: - """Validate and clean extracted tags. - - Args: - tags: List of raw tags - - Returns: - List of validated and cleaned tags - """ - valid_tags = [] - for tag in tags: - if isinstance(tag, str) and len(tag.strip()) > 0: - cleaned_tag = tag.strip().lower() - if cleaned_tag not in valid_tags: - valid_tags.append(cleaned_tag) - return valid_tags - - def validate_categories(self, categories: List[str]) -> List[str]: - """Validate and clean extracted categories. - - Args: - categories: List of raw categories - - Returns: - List of validated and cleaned categories - """ - return self.validate_tags(categories) # Same validation logic - - def get_tagger_info(self) -> Dict[str, Any]: - """Get information about this tagger. - - Returns: - Dictionary with tagger metadata - """ - return { - "name": self.name, - "type": self.__class__.__name__, - "config": self.config, - } - - def __str__(self) -> str: - """String representation.""" - return f"{self.__class__.__name__}(name='{self.name}')" diff --git a/quantmind/tagger/llm_tagger.py b/quantmind/tagger/llm_tagger.py deleted file mode 100644 index c214ed2..0000000 --- a/quantmind/tagger/llm_tagger.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Simple LLM-based tagger for financial research papers using LLMBlock.""" - -import json -from typing import List - -from quantmind.config import LLMTaggerConfig -from quantmind.llm import create_llm_block -from quantmind.models import Paper -from quantmind.utils.logger import get_logger - -from .base import BaseTagger - -logger = get_logger(__name__) - - -class LLMTagger(BaseTagger): - """Simple LLM-based tagger for financial research papers. - - Uses LLMBlock to generate relevant tags for quantitative finance papers. - """ - - def __init__( - self, - config: LLMTaggerConfig = None, - ): - """Initialize LLM tagger. - - Args: - config: Configuration for the LLM tagger - """ - super().__init__() - self.config = config or LLMTaggerConfig() - - # Create LLMBlock directly from the embedded LLMConfig - try: - self.llm_block = create_llm_block(self.config.llm_config) - logger.info( - f"Initialized LLM tagger with model: {self.config.llm_config.model}" - ) - except Exception as e: - logger.error(f"Failed to initialize LLM block: {e}") - self.llm_block = None - - def tag_paper(self, paper: Paper) -> Paper: - """Generate tags for a paper using LLM analysis. - - Args: - paper: Paper object to tag - - Returns: - Paper object with added tags - """ - if not self.llm_block: - logger.warning("No LLM block available, skipping tagging") - return paper - - try: - # Get paper content for analysis - content = self._prepare_content(paper) - - # Generate tags using LLM - tags = self._generate_tags(content) - - # Add tags to paper - for tag in tags: - paper.add_tag(tag) - - # Store tagging metadata - paper.meta_info.update( - { - "tagger": "llm_tagger", - "model_used": self.config.llm_config.model, - "tags_generated": len(tags), - } - ) - - logger.info(f"Generated {len(tags)} tags for paper: {paper.title}") - - except Exception as e: - logger.error(f"Error tagging paper {paper.get_primary_id()}: {e}") - - return paper - - def _prepare_content(self, paper: Paper) -> str: - """Prepare paper content for LLM analysis. - - Args: - paper: Paper object - - Returns: - Formatted content string - """ - content_parts = [] - - if paper.title: - content_parts.append(f"Title: {paper.title}") - - if paper.abstract: - content_parts.append(f"Abstract: {paper.abstract}") - - # Use first max_tokens characters of content to stay within token limits - if paper.content: - content_parts.append( - f"Content: {paper.content[: self.config.llm_config.max_tokens]}..." - ) - - return "\n\n".join(content_parts) - - def _generate_tags(self, content: str) -> List[str]: - """Generate tags using LLM. - - Args: - content: Paper content to analyze - - Returns: - List of generated tags - """ - prompt = self._build_prompt(content) - - try: - response = self.llm_block.generate_text(prompt) - - if not response: - logger.error("No response from LLM") - return [] - - logger.debug(f"LLM response: {response}") - - # Parse tags from response - tags = self._parse_tags(response) - - # Limit to max_tags - return tags[: self.config.max_tags] - - except Exception as e: - logger.error(f"Error generating tags: {e}") - return [] - - def _build_prompt(self, content: str) -> str: - """Build prompt for tag generation. - - Args: - content: Paper content - - Returns: - Formatted prompt - """ - if self.config.custom_prompt: - return self.config.custom_prompt.format( - content=content, max_tags=self.config.max_tags - ) - - # Default prompt for quantitative finance papers - return f"""Analyze this quantitative finance research paper and generate {self.config.max_tags} relevant tags. - -Paper Content: -{content} - -Generate tags that capture the key aspects like: -- Market types (equity, forex, crypto, bonds) -- Methods (machine learning, deep learning, statistical) -- Applications (trading, risk management, portfolio optimization) -- Data types (price data, news, sentiment) -- Techniques (LSTM, transformers, regression) - -Return only a JSON list of tags, no other text: -["tag1", "tag2", "tag3", "tag4", "tag5"]""" - - def _parse_tags(self, response: str) -> List[str]: - """Parse tags from LLM response. - - Args: - response: Raw LLM response - - Returns: - List of parsed tags - """ - try: - # Try to find JSON array in response - start_idx = response.find("[") - end_idx = response.rfind("]") + 1 - - if start_idx != -1 and end_idx > start_idx: - json_str = response[start_idx:end_idx] - tags = json.loads(json_str) - - if isinstance(tags, list): - # Clean and validate tags - cleaned_tags = [] - for tag in tags: - if isinstance(tag, str) and tag.strip(): - cleaned_tags.append(tag.strip().lower()) - - return cleaned_tags - - except (json.JSONDecodeError, ValueError) as e: - logger.warning(f"Failed to parse JSON tags: {e}") - - # Fallback: try to extract tags from plain text - return self._extract_tags_from_text(response) - - def _extract_tags_from_text(self, text: str) -> List[str]: - """Extract tags from plain text response as fallback. - - Args: - text: Response text - - Returns: - List of extracted tags - """ - # Simple extraction: look for quoted words or comma-separated items - import re - - # Try to find quoted items first - quoted_items = re.findall(r'"([^"]*)"', text) - if quoted_items: - return [ - item.strip().lower() for item in quoted_items if item.strip() - ] - - # Try comma-separated items - lines = text.split("\n") - for line in lines: - if "," in line and not line.startswith("#"): - items = [item.strip().lower() for item in line.split(",")] - if len(items) >= 2: - return [item for item in items if item and len(item) > 1] - - logger.warning("Could not extract tags from response") - return [] - - def extract_tags(self, text: str, title: str = "") -> List[str]: - """Extract tags from arbitrary text. - - Args: - text: Text content to analyze - title: Optional title for context - - Returns: - List of extracted tags - """ - if not self.llm_block: - return [] - - content = f"Title: {title}\n\nContent: {text}" if title else text - return self._generate_tags(content) - - def test_connection(self) -> bool: - """Test if the LLM connection is working. - - Returns: - True if connection is working, False otherwise - """ - if not self.llm_block: - return False - - return self.llm_block.test_connection() - - @property - def llm_type(self) -> str: - """Get the LLM type.""" - return "openai" # Default, can be made configurable if needed - - @property - def llm_name(self) -> str: - """Get the LLM name.""" - return self.config.llm_config.model diff --git a/quantmind/tools/__init__.py b/quantmind/tools/__init__.py deleted file mode 100644 index 46872f1..0000000 --- a/quantmind/tools/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""QuantMind tools module. - -This module provides the core tool infrastructure, primarily based on the Smolagents -implementation (@tools.py) as a starting point. It includes base classes, decorators, -and utilities for creating and managing tools within the QuantMind framework. -""" - -from .base import BaseTool, Tool, tool, validate_tool_arguments - -__all__ = [ - "BaseTool", - "Tool", - "tool", - "validate_tool_arguments", -] diff --git a/quantmind/tools/_function_type_hints_utils.py b/quantmind/tools/_function_type_hints_utils.py deleted file mode 100644 index ddb5bad..0000000 --- a/quantmind/tools/_function_type_hints_utils.py +++ /dev/null @@ -1,472 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import inspect -import json -import re -import types -from collections.abc import Callable -from copy import copy -from typing import ( - Any, - Literal, - Union, - get_args, - get_origin, - get_type_hints, -) - -IMPORT_TO_PACKAGE_MAPPING = { - "wikipediaapi": "wikipedia-api", -} - - -def get_package_name(import_name: str) -> str: - """Return the package name for a given import name. - - Args: - import_name (`str`): Import name to get the package name for. - - Returns: - `str`: Package name for the given import name. - """ - return IMPORT_TO_PACKAGE_MAPPING.get(import_name, import_name) - - -def get_imports(code: str) -> list[str]: - """Extracts all the libraries (not relative imports) that are imported in a code. - - Args: - code (`str`): Code text to inspect. - - Returns: - `list[str]`: List of all packages required to use the input code. - """ - # filter out try/except block so in custom code we can have try/except imports - code = re.sub(r"\s*try\s*:.*?except.*?:", "", code, flags=re.DOTALL) - - # filter out imports under is_flash_attn_2_available block for avoid import issues in cpu - # only environment - code = re.sub( - r"if is_flash_attn[a-zA-Z0-9_]+available\(\):\s*(from flash_attn\s*.*\s*)+", - "", - code, - flags=re.MULTILINE, - ) - - # Imports of the form `import xxx` or `import xxx as yyy` - imports = re.findall( - r"^\s*import\s+(\S+?)(?:\s+as\s+\S+)?\s*$", code, flags=re.MULTILINE - ) - # Imports of the form `from xxx import yyy` - imports += re.findall( - r"^\s*from\s+(\S+)\s+import", code, flags=re.MULTILINE - ) - # Only keep the top-level module - imports = [imp.split(".")[0] for imp in imports if not imp.startswith(".")] - return [get_package_name(import_name) for import_name in set(imports)] - - -class TypeHintParsingException(Exception): - """Exception raised for errors in parsing type hints to generate JSON schemas.""" - - -class DocstringParsingException(Exception): - """Exception raised for errors in parsing docstrings to generate JSON schemas.""" - - -def get_json_schema(func: Callable) -> dict: - """Generate a JSON schema for a function based on its docstring and type hints. - - This is mostly used for passing lists of tools to a chat template. The JSON - schema contains the name and description of the function, as well as the - names, types and descriptions for each of its arguments. - `get_json_schema()` requires that the function has a docstring, and that - each argument has a description in the docstring, in the standard Google - docstring format shown below. It also requires that all the function - arguments have a valid Python type hint. - - Although it is not required, a `Returns` block can also be added, which - will be included in the schema. This is optional because most chat - templates ignore the return value of the function. - - Args: - func: The function to generate a JSON schema for. - - Returns: - A dictionary containing the JSON schema for the function. - - Examples: - ```python - >>> def multiply(x: float, y: float): - >>> ''' - >>> A function that multiplies two numbers - >>> - >>> Args: - >>> x: The first number to multiply - >>> y: The second number to multiply - >>> ''' - >>> return x * y - >>> - >>> print(get_json_schema(multiply)) - { - "name": "multiply", - "description": "A function that multiplies two numbers", - "parameters": { - "type": "object", - "properties": { - "x": {"type": "number", "description": "The first number to multiply"}, - "y": {"type": "number", "description": "The second number to multiply"} - }, - "required": ["x", "y"] - } - } - ``` - - The general use for these schemas is that they are used to generate tool descriptions for chat - templates that support them, like so: - - ```python - >>> from transformers import AutoTokenizer - >>> from transformers.utils import get_json_schema - >>> - >>> def multiply(x: float, y: float): - >>> ''' - >>> A function that multiplies two numbers - >>> - >>> Args: - >>> x: The first number to multiply - >>> y: The second number to multiply - >>> return x * y - >>> ''' - >>> - >>> multiply_schema = get_json_schema(multiply) - >>> tokenizer = AutoTokenizer.from_pretrained("CohereForAI/c4ai-command-r-v01") - >>> messages = [{"role": "user", "content": "What is 179 x 4571?"}] - >>> formatted_chat = tokenizer.apply_chat_template( - >>> messages, - >>> tools=[multiply_schema], - >>> chat_template="tool_use", - >>> return_dict=True, - >>> return_tensors="pt", - >>> add_generation_prompt=True - >>> ) - >>> # The formatted chat can now be passed to model.generate() - ``` - - Each argument description can also have an optional `(choices: ...)` block at the end, such as - `(choices: ["tea", "coffee"])`, which will be parsed into an `enum` field in the schema. - Note that this will only be parsed correctly if it is at the end of the line: - - ```python - >>> def drink_beverage(beverage: str): - >>> ''' - >>> A function that drinks a beverage - >>> - >>> Args: - >>> beverage: The beverage to drink (choices: ["tea", "coffee"]) - >>> ''' - >>> pass - >>> - >>> print(get_json_schema(drink_beverage)) - ``` - { - 'name': 'drink_beverage', - 'description': 'A function that drinks a beverage', - 'parameters': { - 'type': 'object', - 'properties': { - 'beverage': { - 'type': 'string', - 'enum': ['tea', 'coffee'], - 'description': 'The beverage to drink' - } - }, - 'required': ['beverage'] - } - } - """ - doc = inspect.getdoc(func) - if not doc: - raise DocstringParsingException( - f"Cannot generate JSON schema for {func.__name__} because it has no docstring!" - ) - doc = doc.strip() - main_doc, param_descriptions, return_doc = _parse_google_format_docstring( - doc - ) - - json_schema = _convert_type_hints_to_json_schema(func) - if ( - return_dict := json_schema["properties"].pop("return", None) - ) is not None: - if ( - return_doc is not None - ): # We allow a missing return docstring since most templates ignore it - return_dict["description"] = return_doc - for arg, schema in json_schema["properties"].items(): - if arg not in param_descriptions: - raise DocstringParsingException( - f"Cannot generate JSON schema for {func.__name__} because the docstring has no description for the argument '{arg}'" - ) - desc = param_descriptions[arg] - enum_choices = re.search( - r"\(choices:\s*(.*?)\)\s*$", desc, flags=re.IGNORECASE - ) - if enum_choices: - schema["enum"] = [ - c.strip() for c in json.loads(enum_choices.group(1)) - ] - desc = enum_choices.string[: enum_choices.start()].strip() - schema["description"] = desc - - output = { - "name": func.__name__, - "description": main_doc, - "parameters": json_schema, - } - if return_dict is not None: - output["return"] = return_dict - return {"type": "function", "function": output} - - -# Extracts the initial segment of the docstring, containing the function description -description_re = re.compile( - r"^(.*?)(?=\n\s*(Args:|Returns:|Raises:)|\Z)", re.DOTALL -) -# Extracts the Args: block from the docstring -args_re = re.compile( - r"\n\s*Args:\n\s*(.*?)[\n\s]*(Returns:|Raises:|\Z)", re.DOTALL -) -# Splits the Args: block into individual arguments -args_split_re = re.compile( - r"(?:^|\n)" # Match the start of the args block, or a newline - r"\s*(\w+)\s*(?:\([^)]*?\))?:\s*" # Capture the argument name (ignore the type) and strip spacing - r"(.*?)\s*" # Capture the argument description, which can span multiple lines, and strip trailing spacing - r"(?=\n\s*\w+\s*(?:\([^)]*?\))?:|\Z)", # Stop when you hit the next argument (with or without type) or the end of the block - re.DOTALL | re.VERBOSE, -) -# Extracts the Returns: block from the docstring, if present. Note that most chat templates ignore -# the return type/doc! -returns_re = re.compile( - r"\n\s*Returns:\n\s*" - r"(?:[^)]*?:\s*)?" # Ignore the return type if present - r"(.*?)" # Capture the return description - r"[\n\s]*(Raises:|\Z)", - re.DOTALL, -) - - -def _parse_google_format_docstring( - docstring: str, -) -> tuple[str | None, dict | None, str | None]: - """Parses a Google-style docstring. - - Parses a Google-style docstring to extract the function description, argument descriptions, - and return description. - - Args: - docstring (str): The docstring to parse. - - Returns: - The function description, arguments, and return description. - """ - # Extract the sections - description_match = description_re.search(docstring) - args_match = args_re.search(docstring) - returns_match = returns_re.search(docstring) - - # Clean and store the sections - description = ( - description_match.group(1).strip() if description_match else None - ) - docstring_args = args_match.group(1).strip() if args_match else None - returns = returns_match.group(1).strip() if returns_match else None - - # Parsing the arguments into a dictionary - if docstring_args is not None: - docstring_args = "\n".join( - [line for line in docstring_args.split("\n") if line.strip()] - ) # Remove blank lines - matches = args_split_re.findall(docstring_args) - args_dict = { - match[0]: re.sub(r"\s*\n+\s*", " ", match[1].strip()) - for match in matches - } - else: - args_dict = {} - - return description, args_dict, returns - - -def _convert_type_hints_to_json_schema( - func: Callable, error_on_missing_type_hints: bool = True -) -> dict: - type_hints = get_type_hints(func) - signature = inspect.signature(func) - - properties = {} - for param_name, param_type in type_hints.items(): - properties[param_name] = _parse_type_hint(param_type) - - required = [] - for param_name, param in signature.parameters.items(): - if ( - param.annotation == inspect.Parameter.empty - and error_on_missing_type_hints - ): - raise TypeHintParsingException( - f"Argument {param.name} is missing a type hint in function {func.__name__}" - ) - if param_name not in properties: - properties[param_name] = {} - - if param.default == inspect.Parameter.empty: - required.append(param_name) - else: - properties[param_name]["nullable"] = True - - # Return: multi‐type union -> treat as any - if ( - "return" in properties - and (return_type := properties["return"].get("type")) - and not isinstance(return_type, str) - ): - properties["return"]["type"] = "any" - - schema = {"type": "object", "properties": properties} - if required: - schema["required"] = required - - return schema - - -def _parse_type_hint(hint: type) -> dict: - origin = get_origin(hint) - args = get_args(hint) - - if origin is None: - try: - return _get_json_schema_type(hint) - except KeyError: - raise TypeHintParsingException( - "Couldn't parse this type hint, likely due to a custom class or object: ", - hint, - ) - - elif origin is Union or ( - hasattr(types, "UnionType") and origin is types.UnionType - ): - return _parse_union_type(args) - - elif origin is list: - if not args: - return {"type": "array"} - else: - # Lists can only have a single type argument, so recurse into it - return {"type": "array", "items": _parse_type_hint(args[0])} - - elif origin is tuple: - if not args: - return {"type": "array"} - if len(args) == 1: - raise TypeHintParsingException( - f"The type hint {str(hint).replace('typing.', '')} is a Tuple with a single element, which " - "we do not automatically convert to JSON schema as it is rarely necessary. If this input can contain " - "more than one element, we recommend " - "using a List[] type instead, or if it really is a single element, remove the Tuple[] wrapper and just " - "pass the element directly." - ) - if ... in args: - raise TypeHintParsingException( - "Conversion of '...' is not supported in Tuple type hints. " - "Use List[] types for variable-length" - " inputs instead." - ) - return { - "type": "array", - "prefixItems": [_parse_type_hint(t) for t in args], - } - - elif origin is dict: - # The JSON equivalent to a dict is 'object', which mandates that all keys are strings - # However, we can specify the type of the dict values with "additionalProperties" - out = {"type": "object"} - if len(args) == 2: - out["additionalProperties"] = _parse_type_hint(args[1]) - return out - - elif origin is Literal: - literal_types = set(type(arg) for arg in args) - final_type = _parse_union_type(literal_types) - - # None literal value is represented by 'nullable' field set by _parse_union_type - final_type.update({"enum": [arg for arg in args if arg is not None]}) - return final_type - - raise TypeHintParsingException( - "Couldn't parse this type hint, likely due to a custom class or object: ", - hint, - ) - - -def _parse_union_type(args: tuple[Any, ...]) -> dict: - subtypes = [_parse_type_hint(t) for t in args if t is not type(None)] - if len(subtypes) == 1: - # A single non-null type can be expressed directly - return_dict = subtypes[0] - elif all(isinstance(subtype["type"], str) for subtype in subtypes): - # A union of basic types can be expressed as a list in the schema - return_dict = { - "type": sorted([subtype["type"] for subtype in subtypes]) - } - else: - # A union of more complex types requires "anyOf" - return_dict = {"anyOf": subtypes} - if type(None) in args: - return_dict["nullable"] = True - return return_dict - - -_BASE_TYPE_MAPPING = { - int: {"type": "integer"}, - float: {"type": "number"}, - str: {"type": "string"}, - bool: {"type": "boolean"}, - list: {"type": "array"}, - dict: {"type": "object"}, - Any: {"type": "any"}, - types.NoneType: {"type": "null"}, -} - - -def _get_json_schema_type(param_type: type) -> dict[str, str]: - if param_type in _BASE_TYPE_MAPPING: - return copy(_BASE_TYPE_MAPPING[param_type]) - if str(param_type) == "Image": - from PIL.Image import Image - - if param_type == Image: - return {"type": "image"} - if str(param_type) == "Tensor": - try: - from torch import Tensor - - if param_type == Tensor: - return {"type": "audio"} - except ModuleNotFoundError: - pass - return {"type": "object"} diff --git a/quantmind/tools/_tool_validation.py b/quantmind/tools/_tool_validation.py deleted file mode 100644 index 368a4a1..0000000 --- a/quantmind/tools/_tool_validation.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import ast -import builtins -from itertools import zip_longest - -from .utils import BASE_BUILTIN_MODULES, get_source, is_valid_name - -_BUILTIN_NAMES = set(vars(builtins)) - - -class MethodChecker(ast.NodeVisitor): - """Checks that a method. - - - only uses defined names - - contains no local imports (e.g. numpy is ok but local_script is not) - """ - - def __init__(self, class_attributes: set[str], check_imports: bool = True): - self.undefined_names = set() - self.imports = {} - self.from_imports = {} - self.assigned_names = set() - self.arg_names = set() - self.class_attributes = class_attributes - self.errors = [] - self.check_imports = check_imports - self.typing_names = {"Any"} - self.defined_classes = set() - - def visit_arguments(self, node): - """Collect function arguments.""" - self.arg_names = {arg.arg for arg in node.args} - if node.kwarg: - self.arg_names.add(node.kwarg.arg) - if node.vararg: - self.arg_names.add(node.vararg.arg) - - def visit_Import(self, node): - for name in node.names: - actual_name = name.asname or name.name - self.imports[actual_name] = name.name - - def visit_ImportFrom(self, node): - module = node.module or "" - for name in node.names: - actual_name = name.asname or name.name - self.from_imports[actual_name] = (module, name.name) - - def visit_Assign(self, node): - for target in node.targets: - if isinstance(target, ast.Name): - self.assigned_names.add(target.id) - elif isinstance(target, (ast.Tuple, ast.List)): - for elt in target.elts: - if isinstance(elt, ast.Name): - self.assigned_names.add(elt.id) - self.visit(node.value) - - def visit_With(self, node): - """Track aliases in 'with' statements (the 'y' in 'with X as y').""" - for item in node.items: - if item.optional_vars: # This is the 'y' in 'with X as y' - if isinstance(item.optional_vars, ast.Name): - self.assigned_names.add(item.optional_vars.id) - self.generic_visit(node) - - def visit_ExceptHandler(self, node): - """Track exception aliases (the 'e' in 'except Exception as e').""" - if node.name: # This is the 'e' in 'except Exception as e' - self.assigned_names.add(node.name) - self.generic_visit(node) - - def visit_AnnAssign(self, node): - """Track annotated assignments.""" - if isinstance(node.target, ast.Name): - self.assigned_names.add(node.target.id) - if node.value: - self.visit(node.value) - - def visit_For(self, node): - target = node.target - if isinstance(target, ast.Name): - self.assigned_names.add(target.id) - elif isinstance(target, ast.Tuple): - for elt in target.elts: - if isinstance(elt, ast.Name): - self.assigned_names.add(elt.id) - self.generic_visit(node) - - def _handle_comprehension_generators(self, generators): - """Helper method to handle generators in all types of comprehensions.""" - for generator in generators: - if isinstance(generator.target, ast.Name): - self.assigned_names.add(generator.target.id) - elif isinstance(generator.target, ast.Tuple): - for elt in generator.target.elts: - if isinstance(elt, ast.Name): - self.assigned_names.add(elt.id) - - def visit_ListComp(self, node): - """Track variables in list comprehensions.""" - self._handle_comprehension_generators(node.generators) - self.generic_visit(node) - - def visit_DictComp(self, node): - """Track variables in dictionary comprehensions.""" - self._handle_comprehension_generators(node.generators) - self.generic_visit(node) - - def visit_SetComp(self, node): - """Track variables in set comprehensions.""" - self._handle_comprehension_generators(node.generators) - self.generic_visit(node) - - def visit_Attribute(self, node): - if not (isinstance(node.value, ast.Name) and node.value.id == "self"): - self.generic_visit(node) - - def visit_ClassDef(self, node): - """Track class definitions.""" - self.defined_classes.add(node.name) - self.generic_visit(node) - - def visit_Name(self, node): - if isinstance(node.ctx, ast.Load): - if not ( - node.id in _BUILTIN_NAMES - or node.id in BASE_BUILTIN_MODULES - or node.id in self.arg_names - or node.id == "self" - or node.id in self.class_attributes - or node.id in self.imports - or node.id in self.from_imports - or node.id in self.assigned_names - or node.id in self.typing_names - or node.id in self.defined_classes - ): - self.errors.append(f"Name '{node.id}' is undefined.") - - def visit_Call(self, node): - if isinstance(node.func, ast.Name): - if not ( - node.func.id in _BUILTIN_NAMES - or node.func.id in BASE_BUILTIN_MODULES - or node.func.id in self.arg_names - or node.func.id == "self" - or node.func.id in self.class_attributes - or node.func.id in self.imports - or node.func.id in self.from_imports - or node.func.id in self.assigned_names - or node.func.id in self.defined_classes - ): - self.errors.append(f"Name '{node.func.id}' is undefined.") - self.generic_visit(node) - - -def validate_tool_attributes(cls, check_imports: bool = True) -> None: - """Validates that a Tool class follows the proper patterns. - - 0. Any argument of __init__ should have a default. - Args chosen at init are not traceable, so we cannot rebuild the source - code for them, thus any important arg should be defined as a class - attribute. - 1. About the class: - - Class attributes should only be strings or dicts - - Class attributes cannot be complex attributes - 2. About all class methods: - - Imports must be from packages, not local files - - All methods must be self-contained - - Raises all errors encountered, if no error returns None. - """ - - class ClassLevelChecker(ast.NodeVisitor): - def __init__(self): - self.imported_names = set() - self.complex_attributes = set() - self.class_attributes = set() - self.non_defaults = set() - self.non_literal_defaults = set() - self.in_method = False - self.invalid_attributes = [] - - def visit_FunctionDef(self, node): - if node.name == "__init__": - self._check_init_function_parameters(node) - old_context = self.in_method - self.in_method = True - self.generic_visit(node) - self.in_method = old_context - - def visit_Assign(self, node): - if self.in_method: - return - # Track class attributes - for target in node.targets: - if isinstance(target, ast.Name): - self.class_attributes.add(target.id) - - # Check if the assignment is more complex than simple literals - if not all( - isinstance(val, (ast.Constant, ast.Dict, ast.List, ast.Set)) - for val in ast.walk(node.value) - ): - for target in node.targets: - if isinstance(target, ast.Name): - self.complex_attributes.add(target.id) - - # Check specific class attributes - if getattr(node.targets[0], "id", "") == "name": - if not isinstance(node.value, ast.Constant): - self.invalid_attributes.append( - f"Class attribute 'name' must be a constant, found '{node.value}'" - ) - elif not isinstance(node.value.value, str): - self.invalid_attributes.append( - f"Class attribute 'name' must be a string, found '{node.value.value}'" - ) - elif not is_valid_name(node.value.value): - self.invalid_attributes.append( - f"Class attribute 'name' must be a valid Python identifier and not a reserved keyword, found '{node.value.value}'" - ) - - def _check_init_function_parameters(self, node): - # Check defaults in parameters - for arg, default in reversed( - list( - zip_longest( - reversed(node.args.args), reversed(node.args.defaults) - ) - ) - ): - if default is None: - if arg.arg != "self": - self.non_defaults.add(arg.arg) - elif not isinstance( - default, (ast.Constant, ast.Dict, ast.List, ast.Set) - ): - self.non_literal_defaults.add(arg.arg) - - class_level_checker = ClassLevelChecker() - source = get_source(cls) - tree = ast.parse(source) - class_node = tree.body[0] - if not isinstance(class_node, ast.ClassDef): - raise ValueError("Source code must define a class") - class_level_checker.visit(class_node) - - errors = [] - # Check invalid class attributes - if class_level_checker.invalid_attributes: - errors += class_level_checker.invalid_attributes - if class_level_checker.complex_attributes: - errors.append( - f"Complex attributes should be defined in __init__, not as class attributes: " - f"{', '.join(class_level_checker.complex_attributes)}" - ) - if class_level_checker.non_defaults: - errors.append( - f"Parameters in __init__ must have default values, found required parameters: " - f"{', '.join(class_level_checker.non_defaults)}" - ) - if class_level_checker.non_literal_defaults: - errors.append( - f"Parameters in __init__ must have literal default values, found non-literal defaults: " - f"{', '.join(class_level_checker.non_literal_defaults)}" - ) - - # Run checks on all methods - for node in class_node.body: - if isinstance(node, ast.FunctionDef): - method_checker = MethodChecker( - class_level_checker.class_attributes, - check_imports=check_imports, - ) - method_checker.visit(node) - errors += [ - f"- {node.name}: {error}" for error in method_checker.errors - ] - - if errors: - raise ValueError( - f"Tool validation failed for {cls.__name__}:\n" + "\n".join(errors) - ) - return diff --git a/quantmind/tools/base.py b/quantmind/tools/base.py deleted file mode 100644 index d702d3a..0000000 --- a/quantmind/tools/base.py +++ /dev/null @@ -1,715 +0,0 @@ -"""Tool system implementation for QuantMind. - -This module provides the core tool infrastructure, primarily based on the Smolagents -implementation (@tools.py) as a starting point. It includes base classes, decorators, -and utilities for creating and managing tools within the QuantMind framework. -""" - -from __future__ import annotations - -import ast -import inspect -import json -import logging -import sys -import textwrap -import types -import warnings -from abc import ABC, abstractmethod -from collections.abc import Callable -from functools import wraps -from pathlib import Path -from typing import Any - -from ._function_type_hints_utils import ( - TypeHintParsingException, - _get_json_schema_type, - get_imports, - get_json_schema, -) -from ._tool_validation import MethodChecker, validate_tool_attributes -from .utils import ( - BASE_BUILTIN_MODULES, - get_source, - instance_to_source, - is_valid_name, -) - -logger = logging.getLogger(__name__) - - -def validate_after_init(cls): - """A class decorator that automatically validates tool arguments after initialization. - - This decorator wraps the class's __init__ method to call validate_arguments() - immediately after the original initialization is complete. This ensures that - any tool instance is validated upon creation without requiring manual validation calls. - - Args: - cls: The class to be decorated (typically a Tool subclass) - - Returns: - The decorated class with automatic post-init validation - """ - original_init = cls.__init__ - - @wraps(original_init) - def new_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - self.validate_arguments() - - cls.__init__ = new_init - return cls - - -AUTHORIZED_TYPES = [ - "string", - "boolean", - "integer", - "number", - "image", - "audio", - "array", - "object", - "any", - "null", -] - -CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} - - -class BaseTool(ABC): - name: str - - @abstractmethod - def __call__(self, *args, **kwargs) -> Any: - pass - - -class Tool(BaseTool): - """A base class for the functions used by the agent. - - Subclass this and implement the `forward` method as well as the following - class attributes. - - Attributes: - description (str): A short description of what your tool does, the - inputs it expects and the output(s) it will return. For instance - 'This is a tool that downloads a file from a `url`. It takes the - `url` as input, and returns the text contained in the file'. - name (str): A performative name that will be used for your tool in - the prompt to the agent. For instance `"text-classifier"` or - `"image_generator"`. - inputs (Dict[str, Dict[str, Union[str, type, bool]]]): The dict of - modalities expected for the inputs. It has one `type` key and a - `description` key. This is used by `launch_gradio_demo` or to make - a nice space from your tool, and also can be used in the generated - description for your tool. - output_type (type): The type of the tool output. This is used by - `launch_gradio_demo` or to make a nice space from your tool, and - also can be used in the generated description for your tool. - output_schema (Dict[str, Any], optional): The JSON schema defining the - expected structure of the tool output. This can be included in - system prompts to help agents understand the expected output - format. Note: This is currently used for informational purposes - only and does not perform actual output validation. - - Note: - You can also override the method [`~Tool.setup`] if your tool has an - expensive operation to perform before being usable (such as loading a - model). [`~Tool.setup`] will be called the first time you use your - tool, but not at instantiation. - """ - - name: str - description: str - inputs: dict[str, dict[str, str | type | bool]] - output_type: str - output_schema: dict[str, Any] | None = None - - def __init__(self, *args, **kwargs): - self.is_initialized = False - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - validate_after_init(cls) - - def validate_arguments(self): - """Validate the tool's arguments. - - This method validates the tool's arguments to ensure they are of the - correct type and format. - """ - required_attributes = { - "description": str, - "name": str, - "inputs": dict, - "output_type": str, - } - # Validate class attributes - for attr, expected_type in required_attributes.items(): - attr_value = getattr(self, attr, None) - if attr_value is None: - raise TypeError(f"You must set an attribute {attr}.") - if not isinstance(attr_value, expected_type): - raise TypeError( - f"Attribute {attr} should have type {expected_type.__name__}, got {type(attr_value)} instead." - ) - - # Validate optional output_schema attribute - output_schema = getattr(self, "output_schema", None) - if output_schema is not None and not isinstance(output_schema, dict): - raise TypeError( - f"Attribute output_schema should have type dict, got {type(output_schema)} instead." - ) - - # - Validate name - if not is_valid_name(self.name): - raise Exception( - f"Invalid Tool name '{self.name}': must be a valid Python identifier and not a reserved keyword" - ) - - # Validate inputs - for input_name, input_content in self.inputs.items(): - assert isinstance( - input_content, dict - ), f"Input '{input_name}' should be a dictionary." - assert ( - "type" in input_content and "description" in input_content - ), f"Input '{input_name}' should have keys 'type' and 'description', has only {list(input_content.keys())}." - # Get input_types as a list, whether from a string or list - if isinstance(input_content["type"], str): - input_types = [input_content["type"]] - elif isinstance(input_content["type"], list): - input_types = input_content["type"] - # Check if all elements are strings - if not all(isinstance(t, str) for t in input_types): - raise TypeError( - f"Input '{input_name}': when type is a list, all elements must be strings, got {input_content['type']}" - ) - else: - raise TypeError( - f"Input '{input_name}': type must be a string or list of strings, got {type(input_content['type']).__name__}" - ) - # Check all types are authorized - invalid_types = [ - t for t in input_types if t not in AUTHORIZED_TYPES - ] - if invalid_types: - raise ValueError( - f"Input '{input_name}': types {invalid_types} must be one of {AUTHORIZED_TYPES}" - ) - # Validate output type - assert getattr(self, "output_type", None) in AUTHORIZED_TYPES - - def forward(self, *args, **kwargs): - """Implement the forward method in your subclass of `Tool`.""" - raise NotImplementedError( - "Write this method in your subclass of `Tool`." - ) - - def __call__(self, *args, **kwargs): - """Call the tool. - - This method calls the tool's forward method with the given arguments. - """ - if not self.is_initialized: - self.setup() - - # Handle the arguments might be passed as a single dictionary - if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], dict): - potential_kwargs = args[0] - - # If the dictionary keys match our input parameters, convert it to kwargs - if all(key in self.inputs for key in potential_kwargs): - args = () - kwargs = potential_kwargs - - outputs = self.forward(*args, **kwargs) - return outputs - - def setup(self): - """Setup the tool. - - Overwrite this method here for any operation that is expensive and needs to be executed - before you start using your tool. Such as loading a big model. - """ - self.is_initialized = True - - def to_code_prompt(self) -> str: - """TODO: Add docstring & example for `to_code_prompt` function.""" - args_signature = ", ".join( - f"{arg_name}: {arg_schema['type']}" - for arg_name, arg_schema in self.inputs.items() - ) - - # Use dict type for tools with output schema to indicate structured return - has_schema = ( - hasattr(self, "output_schema") and self.output_schema is not None - ) - output_type = "dict" if has_schema else self.output_type - tool_signature = f"({args_signature}) -> {output_type}" - tool_doc = self.description - - # Add an important note for smaller models (e.g. Mistral Small, Gemma 3, etc.) - # to properly handle structured output. - if has_schema: - IMPORTANT_NOTE_FOR_SMALL_MODELS = "Important: This tool returns structured output! Use the JSON schema below to directly access fields like result['field_name']. NO print() statements needed to inspect the output!" - tool_doc += "\n\n" + IMPORTANT_NOTE_FOR_SMALL_MODELS - - # Add arguments documentation - if self.inputs: - args_descriptions = "\n".join( - f"{arg_name}: {arg_schema['description']}" - for arg_name, arg_schema in self.inputs.items() - ) - args_doc = f"Args:\n{textwrap.indent(args_descriptions, ' ')}" - tool_doc += f"\n\n{args_doc}" - - # Add returns documentation with output schema if it exists - if has_schema: - formatted_schema = json.dumps(self.output_schema, indent=4) - indented_schema = textwrap.indent(formatted_schema, " ") - returns_doc = f"\nReturns:\n dict (structured output): This tool ALWAYS returns a dictionary that strictly adheres to the following JSON schema:\n{indented_schema}" - tool_doc += f"\n{returns_doc}" - - tool_doc = f'"""{tool_doc}\n"""' - return f"def {self.name}{tool_signature}:\n{textwrap.indent(tool_doc, ' ')}" - - def to_tool_calling_prompt(self) -> str: - return f"{self.name}: {self.description}\n Takes inputs: {self.inputs}\n Returns an output of type: {self.output_type}" - - def to_dict(self) -> dict: - """Returns a dictionary representing the tool. - - Note: Inherit from Smolagents impl. - """ - class_name = self.__class__.__name__ - if type(self).__name__ == "SimpleTool": - # Check that imports are self-contained - source_code = get_source(self.forward).replace("@tool", "") - forward_node = ast.parse(source_code) - # If tool was created using '@tool' decorator, - # it has only a forward pass, so it's simpler to just get its code - method_checker = MethodChecker(set()) - method_checker.visit(forward_node) - - if len(method_checker.errors) > 0: - errors = [f"- {error}" for error in method_checker.errors] - raise ( - ValueError( - f"SimpleTool validation failed for {self.name}:\n" - + "\n".join(errors) - ) - ) - - forward_source_code = get_source(self.forward) - tool_code = textwrap.dedent( - f""" - from quantmind import Tool - from typing import Any, Optional - - class {class_name}(Tool): - name = "{self.name}" - description = {json.dumps(textwrap.dedent(self.description).strip())} - inputs = {repr(self.inputs)} - output_type = "{self.output_type}" - """ - ).strip() - - # Add output_schema if it exists - if ( - hasattr(self, "output_schema") - and self.output_schema is not None - ): - tool_code += f"\n output_schema = {repr(self.output_schema)}" - import re - - def add_self_argument(source_code: str) -> str: - """Add 'self' as first argument to a function definition if not present.""" - pattern = r"def forward\(((?!self)[^)]*)\)" - - def replacement(match): - args = match.group(1).strip() - if args: # If there are other arguments - return f"def forward(self, {args})" - return "def forward(self)" - - return re.sub(pattern, replacement, source_code) - - forward_source_code = forward_source_code.replace( - self.name, "forward" - ) - forward_source_code = add_self_argument(forward_source_code) - forward_source_code = forward_source_code.replace( - "@tool", "" - ).strip() - tool_code += "\n\n" + textwrap.indent(forward_source_code, " ") - - else: # If the tool was not created by the @tool decorator, it was made by subclassing Tool - validate_tool_attributes(self.__class__) - - tool_code = ( - "from typing import Any, Optional\n" - + instance_to_source(self, base_cls=Tool) - ) - - requirements = { - el - for el in get_imports(tool_code) - if el not in sys.stdlib_module_names - } | {"quantmind"} - - tool_dict = { - "name": self.name, - "code": tool_code, - "requirements": sorted(requirements), - } - - # Add output_schema if it exists - if hasattr(self, "output_schema") and self.output_schema is not None: - tool_dict["output_schema"] = self.output_schema - - return tool_dict - - @classmethod - def from_dict(cls, tool_dict: dict[str, Any], **kwargs) -> "Tool": - """Create tool from a dictionary representation. - - Args: - tool_dict (`dict[str, Any]`): Dictionary representation of the tool. - **kwargs: Additional keyword arguments to pass to the tool's constructor. - - Returns: - `Tool`: Tool object. - """ - if "code" not in tool_dict: - raise ValueError( - "Tool dictionary must contain 'code' key with the tool source code" - ) - - tool = cls.from_code(tool_dict["code"], **kwargs) - - # Set output_schema if it exists in the dictionary - if "output_schema" in tool_dict: - tool.output_schema = tool_dict["output_schema"] - - return tool - - def save(self, output_dir: str | Path, tool_file_name: str = "tool"): - """Saves the relevant code files for your tool. - - This will copy the code of your tool in `output_dir` as well as autogenerate: - - - a `{tool_file_name}.py` file containing the logic for your tool. - - Args: - output_dir (`str` or `Path`): The folder in which you want to save your tool. - tool_file_name (`str`, *optional*): The file name in which you want to save your tool. - """ - # Ensure output directory exists - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - # Save tool file - self._write_file( - output_path / f"{tool_file_name}.py", self._get_tool_code() - ) - - def _write_file(self, file_path: Path, content: str) -> None: - """Writes content to a file with UTF-8 encoding.""" - file_path.write_text(content, encoding="utf-8") - - def _get_tool_code(self) -> str: - """Get the tool's code.""" - return self.to_dict()["code"] - - def _get_requirements(self) -> str: - """Get the requirements.""" - return "\n".join(self.to_dict()["requirements"]) - - @classmethod - def from_code(cls, tool_code: str, **kwargs): - module = types.ModuleType("dynamic_tool") - - exec(tool_code, module.__dict__) - - # Find the Tool subclass - tool_class = next( - ( - obj - for _, obj in inspect.getmembers(module, inspect.isclass) - if issubclass(obj, Tool) and obj is not Tool - ), - None, - ) - - if tool_class is None: - raise ValueError("No Tool subclass found in the code.") - - # Convert inputs from string representation to dictionary if needed - # When tool code is serialized/deserialized, complex data structures like - # dictionaries may be stored as string literals (e.g., "{'key': 'value'}") - # ast.literal_eval safely evaluates these string literals back to Python objects - if not isinstance(tool_class.inputs, dict): - tool_class.inputs = ast.literal_eval(tool_class.inputs) - - # Handle output_schema if it exists and is a string representation - # Similar to inputs, output_schema might be serialized as a string literal - # and needs to be converted back to its original Python data structure - if hasattr(tool_class, "output_schema") and isinstance( - tool_class.output_schema, str - ): - # ast.literal_eval is safer than eval() as it only evaluates literals - # (strings, numbers, tuples, lists, dicts, booleans, None) - # and prevents execution of arbitrary code - tool_class.output_schema = ast.literal_eval( - tool_class.output_schema - ) - - return tool_class(**kwargs) - - -def add_description(description): - """A decorator that adds a description to a function.""" - - def inner(func): - func.description = description - func.name = func.__name__ - return func - - return inner - - -def tool(tool_function: Callable) -> Tool: - """Convert a function into an instance of a dynamically created Tool subclass. - - Args: - tool_function (`Callable`): Function to convert into a Tool subclass. - Should have type hints for each input and a type hint for the output. - Should also have a docstring including the description of the function - and an 'Args:' part where each argument is described. - """ - tool_json_schema = get_json_schema(tool_function)["function"] - if "return" not in tool_json_schema: - if len(tool_json_schema["parameters"]["properties"]) == 0: - tool_json_schema["return"] = {"type": "null"} - else: - raise TypeHintParsingException( - "Tool return type not found: make sure your function has a return type hint!" - ) - - class SimpleTool(Tool): - def __init__(self): - self.is_initialized = True - - # Set the class attributes - SimpleTool.name = tool_json_schema["name"] - SimpleTool.description = tool_json_schema["description"] - SimpleTool.inputs = tool_json_schema["parameters"]["properties"] - SimpleTool.output_type = tool_json_schema["return"]["type"] - - # Set output_schema if it exists in the JSON schema - if "output_schema" in tool_json_schema: - SimpleTool.output_schema = tool_json_schema["output_schema"] - elif ( - "return" in tool_json_schema and "schema" in tool_json_schema["return"] - ): - SimpleTool.output_schema = tool_json_schema["return"]["schema"] - - @wraps(tool_function) - def wrapped_function(*args, **kwargs): - return tool_function(*args, **kwargs) - - # Bind the copied function to the forward method - SimpleTool.forward = staticmethod(wrapped_function) - - # Get the signature parameters of the tool function - sig = inspect.signature(tool_function) - # - Add "self" as first parameter to tool_function signature - new_sig = sig.replace( - parameters=[ - inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD) - ] - + list(sig.parameters.values()) - ) - # - Set the signature of the forward method - SimpleTool.forward.__signature__ = new_sig - - # Create and attach the source code of the dynamically created tool class and forward method - # - Get the source code of tool_function - tool_source = textwrap.dedent(inspect.getsource(tool_function)) - # - Remove the tool decorator and function definition line - lines = tool_source.splitlines() - tree = ast.parse(tool_source) - # - Find function definition - func_node = next( - (node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)), - None, - ) - if not func_node: - raise ValueError( - f"No function definition found in the provided source of {tool_function.__name__}. " - "Ensure the input is a standard function." - ) - # - Extract decorator lines - decorator_lines = "" - if func_node.decorator_list: - tool_decorators = [ - d - for d in func_node.decorator_list - if isinstance(d, ast.Name) and d.id == "tool" - ] - if len(tool_decorators) > 1: - raise ValueError( - f"Multiple @tool decorators found on function '{func_node.name}'. Only one @tool decorator is allowed." - ) - if len(tool_decorators) < len(func_node.decorator_list): - warnings.warn( - f"Function '{func_node.name}' has decorators other than @tool. " - "This may cause issues with serialization in the remote executor. See issue #1626." - ) - decorator_start = ( - tool_decorators[0].end_lineno if tool_decorators else 0 - ) - decorator_end = func_node.decorator_list[-1].end_lineno - decorator_lines = "\n".join(lines[decorator_start:decorator_end]) - # - Extract tool source body - body_start = func_node.body[0].lineno - 1 # AST lineno starts at 1 - tool_source_body = "\n".join(lines[body_start:]) - # - Create the forward method source, including def line and indentation - forward_method_source = f"def forward{new_sig}:\n{tool_source_body}" - # - Create the class source - indent = " " * 4 # for class method - class_source = ( - textwrap.dedent(f""" - class SimpleTool(Tool): - name: str = "{tool_json_schema["name"]}" - description: str = {json.dumps(textwrap.dedent(tool_json_schema["description"]).strip())} - inputs: dict[str, dict[str, str]] = {tool_json_schema["parameters"]["properties"]} - output_type: str = "{tool_json_schema["return"]["type"]}" - - def __init__(self): - self.is_initialized = True - - """) - + textwrap.indent(decorator_lines, indent) - + textwrap.indent(forward_method_source, indent) - ) - # - Store the source code on both class and method for inspection - SimpleTool.__source__ = class_source - SimpleTool.forward.__source__ = forward_method_source - - simple_tool = SimpleTool() - return simple_tool - - -def get_tools_definition_code(tools: dict[str, Tool]) -> str: - """Get the tools definition code. - - This function gets the tools definition code. - - Args: - tools (`dict[str, Tool]`): The tools to get the definition code for. - - Returns: - `str`: The tools definition code. - """ - tool_codes = [] - for tool in tools.values(): - validate_tool_attributes(tool.__class__, check_imports=False) - tool_code = instance_to_source(tool, base_cls=Tool) - tool_code = tool_code.replace("from quantmind.tools import Tool", "") - tool_code += f"\n\n{tool.name} = {tool.__class__.__name__}()\n" - tool_codes.append(tool_code) - - tool_definition_code = "\n".join( - [f"import {module}" for module in BASE_BUILTIN_MODULES] - ) - tool_definition_code += textwrap.dedent( - """ - from typing import Any - - class Tool: - def __call__(self, *args, **kwargs): - return self.forward(*args, **kwargs) - - def forward(self, *args, **kwargs): - pass # to be implemented in child class - """ - ) - tool_definition_code += "\n\n".join(tool_codes) - return tool_definition_code - - -def validate_tool_arguments(tool: Tool, arguments: Any) -> None: - """Validate tool arguments against tool's input schema. - - Checks that all provided arguments match the tool's expected input types and that - all required arguments are present. Supports both dictionary arguments and single - value arguments for tools with one input parameter. - - Args: - tool (`Tool`): Tool whose input schema will be used for validation. - arguments (`Any`): Arguments to validate. Can be a dictionary mapping - argument names to values, or a single value for tools with one input. - - - Raises: - ValueError: If an argument is not in the tool's input schema, if a required - argument is missing, or if the argument value doesn't match the expected type. - TypeError: If an argument has an incorrect type that cannot be converted - (e.g., string instead of number, excluding integer to number conversion). - - Note: - - Supports type coercion from integer to number - - Handles nullable parameters when explicitly marked in the schema - - Accepts "any" type as a wildcard that matches all types - """ - if isinstance(arguments, dict): - for key, value in arguments.items(): - if key not in tool.inputs: - raise ValueError( - f"Argument {key} is not in the tool's input schema" - ) - - actual_type = _get_json_schema_type(type(value))["type"] - expected_type = tool.inputs[key]["type"] - expected_type_is_nullable = tool.inputs[key].get("nullable", False) - - # Type is valid if it matches, is "any", or is null for nullable parameters - if ( - ( - actual_type != expected_type - if isinstance(expected_type, str) - else actual_type not in expected_type - ) - and expected_type != "any" - and not (actual_type == "null" and expected_type_is_nullable) - ): - if actual_type == "integer" and expected_type == "number": - continue - raise TypeError( - f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'" - ) - - for key, schema in tool.inputs.items(): - key_is_nullable = schema.get("nullable", False) - if key not in arguments and not key_is_nullable: - raise ValueError(f"Argument {key} is required") - return None - else: - expected_type = list(tool.inputs.values())[0]["type"] - if ( - _get_json_schema_type(type(arguments))["type"] != expected_type - and not expected_type == "any" - ): - raise TypeError( - f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'" - ) - - -__all__ = [ - "AUTHORIZED_TYPES", - "Tool", - "tool", -] diff --git a/quantmind/tools/utils.py b/quantmind/tools/utils.py deleted file mode 100644 index 16056d5..0000000 --- a/quantmind/tools/utils.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import ast -import inspect -import json -import keyword -from textwrap import dedent - -BASE_BUILTIN_MODULES = [ - "collections", - "datetime", - "itertools", - "math", - "queue", - "random", - "re", - "stat", - "statistics", - "time", - "unicodedata", -] - - -class ImportFinder(ast.NodeVisitor): - """Finds the packages imported in a code.""" - - def __init__(self): - self.packages = set() - - def visit_Import(self, node): - for alias in node.names: - # Get the base package name (before any dots) - base_package = alias.name.split(".")[0] - self.packages.add(base_package) - - def visit_ImportFrom(self, node): - if node.module: # for "from x import y" statements - # Get the base package name (before any dots) - base_package = node.module.split(".")[0] - self.packages.add(base_package) - - -def instance_to_source(instance, base_cls=None): - """Convert an instance to its class source code representation.""" - cls = instance.__class__ - class_name = cls.__name__ - - # Start building class lines - class_lines = [] - if base_cls: - class_lines.append(f"class {class_name}({base_cls.__name__}):") - else: - class_lines.append(f"class {class_name}:") - - # Add docstring if it exists and differs from base - if cls.__doc__ and (not base_cls or cls.__doc__ != base_cls.__doc__): - class_lines.append(f' """{cls.__doc__}"""') - - # Add class-level attributes - class_attrs = { - name: value - for name, value in cls.__dict__.items() - if not name.startswith("__") - and not name == "_abc_impl" - and not callable(value) - and not ( - base_cls - and hasattr(base_cls, name) - and getattr(base_cls, name) == value - ) - } - - for name, value in class_attrs.items(): - if isinstance(value, str): - # multiline value - if "\n" in value: - escaped_value = value.replace( - '"""', r"\"\"\"" - ) # Escape triple quotes - class_lines.append(f' {name} = """{escaped_value}"""') - else: - class_lines.append(f" {name} = {json.dumps(value)}") - else: - class_lines.append(f" {name} = {repr(value)}") - - if class_attrs: - class_lines.append("") - - # Add methods - methods = { - name: func.__wrapped__ if hasattr(func, "__wrapped__") else func - for name, func in cls.__dict__.items() - if callable(func) - and ( - not base_cls - or not hasattr(base_cls, name) - or ( - isinstance(func, (staticmethod, classmethod)) - or ( - getattr(base_cls, name).__code__.co_code - != func.__code__.co_code - ) - ) - ) - } - - for name, method in methods.items(): - method_source = get_source(method) - # Clean up the indentation - method_lines = method_source.split("\n") - first_line = method_lines[0] - indent = len(first_line) - len(first_line.lstrip()) - method_lines = [line[indent:] for line in method_lines] - method_source = "\n".join( - [" " + line if line.strip() else line for line in method_lines] - ) - class_lines.append(method_source) - class_lines.append("") - - # Find required imports using ImportFinder - import_finder = ImportFinder() - import_finder.visit(ast.parse("\n".join(class_lines))) - required_imports = import_finder.packages - - # Build final code with imports - final_lines = [] - - # Add base class import if needed - if base_cls: - final_lines.append( - f"from {base_cls.__module__} import {base_cls.__name__}" - ) - - # Add discovered imports - for package in required_imports: - final_lines.append(f"import {package}") - - if final_lines: # Add empty line after imports - final_lines.append("") - - # Add the class code - final_lines.extend(class_lines) - - return "\n".join(final_lines) - - -def get_source(obj) -> str: - """Get the source code of a class or callable object (e.g.: function, method). - - First attempts to get the source code using `inspect.getsource`. - In a dynamic environment (e.g.: Jupyter, IPython), if this fails, - falls back to retrieving the source code from the current interactive shell session. - - Args: - obj: A class or callable object (e.g.: function, method) - - Returns: - str: The source code of the object, dedented and stripped - - Raises: - TypeError: If object is not a class or callable - OSError: If source code cannot be retrieved from any source - ValueError: If source cannot be found in IPython history - - Note: - TODO: handle Python standard REPL - """ - if not (isinstance(obj, type) or callable(obj)): - raise TypeError(f"Expected class or callable, got {type(obj)}") - - inspect_error = None - try: - # Handle dynamically created classes - source = getattr(obj, "__source__", None) or inspect.getsource(obj) - return dedent(source).strip() - except OSError as e: - # let's keep track of the exception to raise it if all further methods fail - inspect_error = e - try: - import IPython - - shell = IPython.get_ipython() - if not shell: - raise ImportError("No active IPython shell found") - all_cells = "\n".join(shell.user_ns.get("In", [])).strip() - if not all_cells: - raise ValueError("No code cells found in IPython session") - - tree = ast.parse(all_cells) - for node in ast.walk(tree): - if ( - isinstance(node, (ast.ClassDef, ast.FunctionDef)) - and node.name == obj.__name__ - ): - return dedent( - "\n".join( - all_cells.split("\n")[node.lineno - 1 : node.end_lineno] - ) - ).strip() - raise ValueError( - f"Could not find source code for {obj.__name__} in IPython history" - ) - except ImportError: - # IPython is not available, let's just raise the original inspect error - raise inspect_error - except ValueError as e: - # IPython is available but we couldn't find the source code, let's raise the error - raise e from inspect_error - - -def is_valid_name(name: str) -> bool: - """Check if a name is a valid Python identifier.""" - return ( - name.isidentifier() and not keyword.iskeyword(name) - if isinstance(name, str) - else False - ) diff --git a/quantmind/utils/agentic_ext.py b/quantmind/utils/agentic_ext.py deleted file mode 100644 index 8059ee0..0000000 --- a/quantmind/utils/agentic_ext.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import ast -import base64 -import importlib.util -import inspect -import json -import keyword -import os -import re -import time -from functools import lru_cache -from io import BytesIO -from pathlib import Path -from textwrap import dedent -from typing import TYPE_CHECKING, Any - -import jinja2 - -if TYPE_CHECKING: - from .monitoring import AgentLogger - - -__all__ = ["AgentError"] - - -@lru_cache -def _is_package_available(package_name: str) -> bool: - return importlib.util.find_spec(package_name) is not None - - -BASE_BUILTIN_MODULES = [ - "collections", - "datetime", - "itertools", - "math", - "queue", - "random", - "re", - "stat", - "statistics", - "time", - "unicodedata", -] - - -def escape_code_brackets(text: str) -> str: - """Escapes square brackets in code segments while preserving Rich styling tags.""" - - def replace_bracketed_content(match): - content = match.group(1) - cleaned = re.sub( - r"bold|red|green|blue|yellow|magenta|cyan|white|black|italic|dim|\s|#[0-9a-fA-F]{6}", - "", - content, - ) - return f"\\[{content}\\]" if cleaned.strip() else f"[{content}]" - - return re.sub(r"\[([^\]]*)\]", replace_bracketed_content, text) - - -class AgentError(Exception): - """Base class for other agent-related exceptions.""" - - def __init__(self, message, logger: "AgentLogger"): - super().__init__(message) - self.message = message - logger.log_error(message) - - def dict(self) -> dict[str, str]: - return {"type": self.__class__.__name__, "message": str(self.message)} - - -class AgentParsingError(AgentError): - """Exception raised for errors in parsing in the agent.""" - - pass - - -class AgentExecutionError(AgentError): - """Exception raised for errors in execution in the agent.""" - - pass - - -class AgentMaxStepsError(AgentError): - """Exception raised for errors in execution in the agent.""" - - pass - - -class AgentToolCallError(AgentExecutionError): - """Exception raised for errors when incorrect arguments are passed to the tool.""" - - pass - - -class AgentToolExecutionError(AgentExecutionError): - """Exception raised for errors when executing a tool.""" - - pass - - -class AgentGenerationError(AgentError): - """Exception raised for errors in generation in the agent.""" - - pass - - -def make_json_serializable(obj: Any) -> Any: - """Recursive function to make objects JSON serializable.""" - if obj is None: - return None - elif isinstance(obj, (str, int, float, bool)): - # Try to parse string as JSON if it looks like a JSON object/array - if isinstance(obj, str): - try: - if (obj.startswith("{") and obj.endswith("}")) or ( - obj.startswith("[") and obj.endswith("]") - ): - parsed = json.loads(obj) - return make_json_serializable(parsed) - except json.JSONDecodeError: - pass - return obj - elif isinstance(obj, (list, tuple)): - return [make_json_serializable(item) for item in obj] - elif isinstance(obj, dict): - return {str(k): make_json_serializable(v) for k, v in obj.items()} - elif hasattr(obj, "__dict__"): - # For custom objects, convert their __dict__ to a serializable format - return { - "_type": obj.__class__.__name__, - **{k: make_json_serializable(v) for k, v in obj.__dict__.items()}, - } - else: - # For any other type, convert to string - return str(obj) - - -def parse_json_blob(json_blob: str) -> tuple[dict[str, str], str]: - """Extracts the JSON blob from the input and returns the JSON data and the rest of the input.""" - try: - first_accolade_index = json_blob.find("{") - last_accolade_index = [ - a.start() for a in list(re.finditer("}", json_blob)) - ][-1] - json_str = json_blob[first_accolade_index : last_accolade_index + 1] - json_data = json.loads(json_str, strict=False) - return json_data, json_blob[:first_accolade_index] - except IndexError: - raise ValueError("The model output does not contain any JSON blob.") - except json.JSONDecodeError as e: - place = e.pos - if json_blob[place - 1 : place + 2] == "},\n": - raise ValueError( - "JSON is invalid: you probably tried to provide multiple tool calls in one action. PROVIDE ONLY ONE TOOL CALL." - ) - raise ValueError( - f"The JSON blob you used is invalid due to the following error: {e}.\n" - f"JSON blob was: {json_blob}, decoding failed on that specific part of the blob:\n" - f"'{json_blob[place - 4 : place + 5]}'." - ) - - -def extract_code_from_text( - text: str, code_block_tags: tuple[str, str] -) -> str | None: - """Extract code from the LLM's output.""" - pattern = rf"{code_block_tags[0]}(.*?){code_block_tags[1]}" - matches = re.findall(pattern, text, re.DOTALL) - if matches: - return "\n\n".join(match.strip() for match in matches) - return None - - -def parse_code_blobs(text: str, code_block_tags: tuple[str, str]) -> str: - """Extract code blocs from the LLM's output. - - If a valid code block is passed, it returns it directly. - - Args: - text (`str`): LLM's output text to parse. - code_block_tags (`tuple[str, str]`): Tuple of code block tags. - - Returns: - `str`: Extracted code block. - - Raises: - ValueError: If no valid code block is found in the text. - """ - matches = extract_code_from_text(text, code_block_tags) - if not matches: # Fallback to markdown pattern - matches = extract_code_from_text(text, ("```(?:python|py)", "\n```")) - if matches: - return matches - # Maybe the LLM outputted a code blob directly - try: - ast.parse(text) - return text - except SyntaxError: - pass - - if "final" in text and "answer" in text: - raise ValueError( - dedent( - f""" - Your code snippet is invalid, because the regex pattern {code_block_tags[0]}(.*?){code_block_tags[1]} was not found in it. - Here is your code snippet: - {text} - It seems like you're trying to return the final answer, you can do it as follows: - {code_block_tags[0]} - final_answer("YOUR FINAL ANSWER HERE") - {code_block_tags[1]} - """ - ).strip() - ) - raise ValueError( - dedent( - f""" - Your code snippet is invalid, because the regex pattern {code_block_tags[0]}(.*?){code_block_tags[1]} was not found in it. - Here is your code snippet: - {text} - Make sure to include code with the correct pattern, for instance: - Thoughts: Your thoughts - {code_block_tags[0]} - # Your python code here - {code_block_tags[1]} - """ - ).strip() - ) - - -MAX_LENGTH_TRUNCATE_CONTENT = 20000 - - -def truncate_content( - content: str, max_length: int = MAX_LENGTH_TRUNCATE_CONTENT -) -> str: - if len(content) <= max_length: - return content - else: - return ( - content[: max_length // 2] - + f"\n..._This content has been truncated to stay below {max_length} characters_...\n" - + content[-max_length // 2 :] - ) - - -class ImportFinder(ast.NodeVisitor): - """Import finder class.""" - - def __init__(self): - self.packages = set() - - def visit_Import(self, node): - for alias in node.names: - # Get the base package name (before any dots) - base_package = alias.name.split(".")[0] - self.packages.add(base_package) - - def visit_ImportFrom(self, node): - if node.module: # for "from x import y" statements - # Get the base package name (before any dots) - base_package = node.module.split(".")[0] - self.packages.add(base_package) - - -def instance_to_source(instance, base_cls=None): - """Convert an instance to its class source code representation.""" - cls = instance.__class__ - class_name = cls.__name__ - - # Start building class lines - class_lines = [] - if base_cls: - class_lines.append(f"class {class_name}({base_cls.__name__}):") - else: - class_lines.append(f"class {class_name}:") - - # Add docstring if it exists and differs from base - if cls.__doc__ and (not base_cls or cls.__doc__ != base_cls.__doc__): - class_lines.append(f' """{cls.__doc__}"""') - - # Add class-level attributes - class_attrs = { - name: value - for name, value in cls.__dict__.items() - if not name.startswith("__") - and not name == "_abc_impl" - and not callable(value) - and not ( - base_cls - and hasattr(base_cls, name) - and getattr(base_cls, name) == value - ) - } - - for name, value in class_attrs.items(): - if isinstance(value, str): - # multiline value - if "\n" in value: - escaped_value = value.replace( - '"""', r"\"\"\"" - ) # Escape triple quotes - class_lines.append(f' {name} = """{escaped_value}"""') - else: - class_lines.append(f" {name} = {json.dumps(value)}") - else: - class_lines.append(f" {name} = {repr(value)}") - - if class_attrs: - class_lines.append("") - - # Add methods - methods = { - name: func.__wrapped__ if hasattr(func, "__wrapped__") else func - for name, func in cls.__dict__.items() - if callable(func) - and ( - not base_cls - or not hasattr(base_cls, name) - or ( - isinstance(func, (staticmethod, classmethod)) - or ( - getattr(base_cls, name).__code__.co_code - != func.__code__.co_code - ) - ) - ) - } - - for name, method in methods.items(): - method_source = get_source(method) - # Clean up the indentation - method_lines = method_source.split("\n") - first_line = method_lines[0] - indent = len(first_line) - len(first_line.lstrip()) - method_lines = [line[indent:] for line in method_lines] - method_source = "\n".join( - [" " + line if line.strip() else line for line in method_lines] - ) - class_lines.append(method_source) - class_lines.append("") - - # Find required imports using ImportFinder - import_finder = ImportFinder() - import_finder.visit(ast.parse("\n".join(class_lines))) - required_imports = import_finder.packages - - # Build final code with imports - final_lines = [] - - # Add base class import if needed - if base_cls: - final_lines.append( - f"from {base_cls.__module__} import {base_cls.__name__}" - ) - - # Add discovered imports - for package in required_imports: - final_lines.append(f"import {package}") - - if final_lines: # Add empty line after imports - final_lines.append("") - - # Add the class code - final_lines.extend(class_lines) - - return "\n".join(final_lines) - - -def get_source(obj) -> str: - """Get the source code of a class or callable object (e.g.: function, method). - - First attempts to get the source code using `inspect.getsource`. - In a dynamic environment (e.g.: Jupyter, IPython), if this fails, - falls back to retrieving the source code from the current interactive shell session. - - Args: - obj: A class or callable object (e.g.: function, method) - - Returns: - str: The source code of the object, dedented and stripped - - Raises: - TypeError: If object is not a class or callable - OSError: If source code cannot be retrieved from any source - ValueError: If source cannot be found in IPython history - - Note: - TODO: handle Python standard REPL - """ - if not (isinstance(obj, type) or callable(obj)): - raise TypeError(f"Expected class or callable, got {type(obj)}") - - inspect_error = None - try: - # Handle dynamically created classes - source = getattr(obj, "__source__", None) or inspect.getsource(obj) - return dedent(source).strip() - except OSError as e: - # let's keep track of the exception to raise it if all further methods fail - inspect_error = e - try: - import IPython - - shell = IPython.get_ipython() - if not shell: - raise ImportError("No active IPython shell found") - all_cells = "\n".join(shell.user_ns.get("In", [])).strip() - if not all_cells: - raise ValueError("No code cells found in IPython session") - - tree = ast.parse(all_cells) - for node in ast.walk(tree): - if ( - isinstance(node, (ast.ClassDef, ast.FunctionDef)) - and node.name == obj.__name__ - ): - return dedent( - "\n".join( - all_cells.split("\n")[node.lineno - 1 : node.end_lineno] - ) - ).strip() - raise ValueError( - f"Could not find source code for {obj.__name__} in IPython history" - ) - except ImportError: - # IPython is not available, let's just raise the original inspect error - raise inspect_error - except ValueError as e: - # IPython is available but we couldn't find the source code, let's raise the error - raise e from inspect_error - - -def encode_image_base64(image): - buffered = BytesIO() - image.save(buffered, format="PNG") - return base64.b64encode(buffered.getvalue()).decode("utf-8") - - -def make_image_url(base64_image): - return f"data:image/png;base64,{base64_image}" - - -def make_init_file(folder: str | Path): - os.makedirs(folder, exist_ok=True) - # Create __init__ - with open(os.path.join(folder, "__init__.py"), "w"): - pass - - -def is_valid_name(name: str) -> bool: - return ( - name.isidentifier() and not keyword.iskeyword(name) - if isinstance(name, str) - else False - ) - - -AGENT_GRADIO_APP_TEMPLATE = """import yaml -import os -from smolagents import GradioUI, {{ class_name }}, {{ agent_dict['model']['class'] }} - -# Get current directory path -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) - -{% for tool in tools.values() -%} -from {{managed_agent_relative_path}}tools.{{ tool.name }} import {{ tool.__class__.__name__ }} as {{ tool.name | camelcase }} -{% endfor %} -{% for managed_agent in managed_agents.values() -%} -from {{managed_agent_relative_path}}managed_agents.{{ managed_agent.name }}.app import agent_{{ managed_agent.name }} -{% endfor %} - -model = {{ agent_dict['model']['class'] }}( -{% for key in agent_dict['model']['data'] if key != 'class' -%} - {{ key }}={{ agent_dict['model']['data'][key]|repr }}, -{% endfor %}) - -{% for tool in tools.values() -%} -{{ tool.name }} = {{ tool.name | camelcase }}() -{% endfor %} - -with open(os.path.join(CURRENT_DIR, "prompts.yaml"), 'r') as stream: - prompt_templates = yaml.safe_load(stream) - -{{ agent_name }} = {{ class_name }}( - model=model, - tools=[{% for tool_name in tools.keys() if tool_name != "final_answer" %}{{ tool_name }}{% if not loop.last %}, {% endif %}{% endfor %}], - managed_agents=[{% for subagent_name in managed_agents.keys() %}agent_{{ subagent_name }}{% if not loop.last %}, {% endif %}{% endfor %}], - {% for attribute_name, value in agent_dict.items() if attribute_name not in ["class", "model", "tools", "prompt_templates", "authorized_imports", "managed_agents", "requirements"] -%} - {{ attribute_name }}={{ value|repr }}, - {% endfor %}prompt_templates=prompt_templates -) -if __name__ == "__main__": - GradioUI({{ agent_name }}).launch() -""".strip() - - -def create_agent_gradio_app_template(): - env = jinja2.Environment( - loader=jinja2.BaseLoader(), undefined=jinja2.StrictUndefined - ) - env.filters["repr"] = repr - env.filters["camelcase"] = lambda value: "".join( - word.capitalize() for word in value.split("_") - ) - return env.from_string(AGENT_GRADIO_APP_TEMPLATE) - - -class RateLimiter: - """Simple rate limiter that enforces a minimum delay between consecutive requests. - - This class is useful for limiting the rate of operations such as API requests, - by ensuring that calls to `throttle()` are spaced out by at least a given interval - based on the desired requests per minute. - - If no rate is specified (i.e., `requests_per_minute` is None), rate limiting - is disabled and `throttle()` becomes a no-op. - - Args: - requests_per_minute (`float | None`): Maximum number of allowed requests per minute. - Use `None` to disable rate limiting. - """ - - def __init__(self, requests_per_minute: float | None = None): - self._enabled = requests_per_minute is not None - self._interval = 60.0 / requests_per_minute if self._enabled else 0.0 - self._last_call = 0.0 - - def throttle(self): - """Pause execution to respect the rate limit, if enabled.""" - if not self._enabled: - return - now = time.time() - elapsed = now - self._last_call - if elapsed < self._interval: - time.sleep(self._interval - elapsed) - self._last_call = time.time() diff --git a/quantmind/utils/monitoring.py b/quantmind/utils/monitoring.py deleted file mode 100644 index 3c9cec5..0000000 --- a/quantmind/utils/monitoring.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import json -from dataclasses import dataclass, field -from enum import IntEnum - -from rich import box -from rich.console import Console, Group -from rich.panel import Panel -from rich.rule import Rule -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text -from rich.tree import Tree - -from .agentic_ext import escape_code_brackets - -__all__ = ["AgentLogger", "LogLevel", "Monitor", "TokenUsage", "Timing"] - - -@dataclass -class TokenUsage: - """Contains the token usage information for a given step or run.""" - - input_tokens: int - output_tokens: int - total_tokens: int = field(init=False) - - def __post_init__(self): - self.total_tokens = self.input_tokens + self.output_tokens - - def dict(self): - return { - "input_tokens": self.input_tokens, - "output_tokens": self.output_tokens, - "total_tokens": self.total_tokens, - } - - -@dataclass -class Timing: - """Contains the timing information for a given step or run.""" - - start_time: float - end_time: float | None = None - - @property - def duration(self): - return ( - None if self.end_time is None else self.end_time - self.start_time - ) - - def dict(self): - return { - "start_time": self.start_time, - "end_time": self.end_time, - "duration": self.duration, - } - - def __repr__(self) -> str: - return f"Timing(start_time={self.start_time}, end_time={self.end_time}, duration={self.duration})" - - -class Monitor: - """Monitor class.""" - - def __init__(self, tracked_model, logger): - self.step_durations = [] - self.tracked_model = tracked_model - self.logger = logger - self.total_input_token_count = 0 - self.total_output_token_count = 0 - - def get_total_token_counts(self) -> TokenUsage: - return TokenUsage( - input_tokens=self.total_input_token_count, - output_tokens=self.total_output_token_count, - ) - - def reset(self): - self.step_durations = [] - self.total_input_token_count = 0 - self.total_output_token_count = 0 - - def update_metrics(self, step_log): - """Update the metrics of the monitor. - - Args: - step_log ([`MemoryStep`]): Step log to update the monitor with. - """ - step_duration = step_log.timing.duration - self.step_durations.append(step_duration) - console_outputs = f"[Step {len(self.step_durations)}: Duration {step_duration:.2f} seconds" - - if step_log.token_usage is not None: - self.total_input_token_count += step_log.token_usage.input_tokens - self.total_output_token_count += step_log.token_usage.output_tokens - console_outputs += f"| Input tokens: {self.total_input_token_count:,} | Output tokens: {self.total_output_token_count:,}" - console_outputs += "]" - self.logger.log(Text(console_outputs, style="dim"), level=1) - - -class LogLevel(IntEnum): - """Log level enumerate class.""" - - OFF = -1 # No output - ERROR = 0 # Only errors - INFO = 1 # Normal output (default) - DEBUG = 2 # Detailed output - - -YELLOW_HEX = "#d4b702" - - -class AgentLogger: - """Agent Logger class.""" - - def __init__( - self, level: LogLevel = LogLevel.INFO, console: Console | None = None - ): - self.level = level - if console is None: - self.console = Console(highlight=False) - else: - self.console = console - - def log( # noqa: D417 - self, *args, level: int | str | LogLevel = LogLevel.INFO, **kwargs - ) -> None: - """Logs a message to the console. - - Args: - level (LogLevel, optional): Defaults to LogLevel.INFO. - """ - if isinstance(level, str): - level = LogLevel[level.upper()] - if level <= self.level: - self.console.print(*args, **kwargs) - - def log_error(self, error_message: str) -> None: - self.log( - escape_code_brackets(error_message), - style="bold red", - level=LogLevel.ERROR, - ) - - def log_markdown( - self, - content: str, - title: str | None = None, - level=LogLevel.INFO, - style=YELLOW_HEX, - ) -> None: - markdown_content = Syntax( - content, - lexer="markdown", - theme="github-dark", - word_wrap=True, - ) - if title: - self.log( - Group( - Rule( - "[bold italic]" + title, - align="left", - style=style, - ), - markdown_content, - ), - level=level, - ) - else: - self.log(markdown_content, level=level) - - def log_code( - self, title: str, content: str, level: int = LogLevel.INFO - ) -> None: - self.log( - Panel( - Syntax( - content, - lexer="python", - theme="monokai", - word_wrap=True, - ), - title="[bold]" + title, - title_align="left", - box=box.HORIZONTALS, - ), - level=level, - ) - - def log_rule(self, title: str, level: int = LogLevel.INFO) -> None: - self.log( - Rule( - "[bold]" + title, - characters="━", - style=YELLOW_HEX, - ), - level=LogLevel.INFO, - ) - - def log_task( - self, - content: str, - subtitle: str, - title: str | None = None, - level: LogLevel = LogLevel.INFO, - ) -> None: - self.log( - Panel( - f"\n[bold]{escape_code_brackets(content)}\n", - title="[bold]New run" + (f" - {title}" if title else ""), - subtitle=subtitle, - border_style=YELLOW_HEX, - subtitle_align="left", - ), - level=level, - ) - - def log_messages( - self, messages: list[dict], level: LogLevel = LogLevel.DEBUG - ) -> None: - messages_as_string = "\n".join( - [json.dumps(dict(message), indent=4) for message in messages] - ) - self.log( - Syntax( - messages_as_string, - lexer="markdown", - theme="github-dark", - word_wrap=True, - ), - level=level, - ) - - def visualize_agent_tree(self, agent): - def create_tools_section(tools_dict): - table = Table(show_header=True, header_style="bold") - table.add_column("Name", style="#1E90FF") - table.add_column("Description") - table.add_column("Arguments") - - for name, tool in tools_dict.items(): - args = [ - f"{arg_name} (`{info.get('type', 'Any')}`{', optional' if info.get('optional') else ''}): {info.get('description', '')}" - for arg_name, info in getattr(tool, "inputs", {}).items() - ] - table.add_row( - name, - getattr(tool, "description", str(tool)), - "\n".join(args), - ) - - return Group("🛠️ [italic #1E90FF]Tools:[/italic #1E90FF]", table) - - def get_agent_headline(agent, name: str | None = None): - name_headline = f"{name} | " if name else "" - return f"[bold {YELLOW_HEX}]{name_headline}{agent.__class__.__name__} | {agent.model.model_id}" - - def build_agent_tree(parent_tree, agent_obj): - """Recursively builds the agent tree.""" - parent_tree.add(create_tools_section(agent_obj.tools)) - - if agent_obj.managed_agents: - agents_branch = parent_tree.add( - "🤖 [italic #1E90FF]Managed agents:" - ) - for name, managed_agent in agent_obj.managed_agents.items(): - agent_tree = agents_branch.add( - get_agent_headline(managed_agent, name) - ) - if managed_agent.__class__.__name__ == "CodeAgent": - agent_tree.add( - f"✅ [italic #1E90FF]Authorized imports:[/italic #1E90FF] {managed_agent.additional_authorized_imports}" - ) - agent_tree.add( - f"📝 [italic #1E90FF]Description:[/italic #1E90FF] {managed_agent.description}" - ) - build_agent_tree(agent_tree, managed_agent) - - main_tree = Tree(get_agent_headline(agent)) - if agent.__class__.__name__ == "CodeAgent": - main_tree.add( - f"✅ [italic #1E90FF]Authorized imports:[/italic #1E90FF] {agent.additional_authorized_imports}" - ) - build_agent_tree(main_tree, agent) - self.console.print(main_tree) diff --git a/tests/brain/test_memory.py b/tests/brain/test_memory.py deleted file mode 100644 index d518331..0000000 --- a/tests/brain/test_memory.py +++ /dev/null @@ -1,93 +0,0 @@ -import unittest - -from quantmind.brain.memory import CallbackRegistry, Memory -from quantmind.models.memory import ActionStep, TaskStep, ToolCall -from quantmind.models.messages import ChatMessage, MessageRole -from quantmind.utils.monitoring import Timing, TokenUsage - - -class MemoryTestCase(unittest.TestCase): - """Test memory functionality.""" - - def test_memory_succinct_and_full_steps(self): - memory = Memory("system prompt") - task_step = TaskStep(task="Investigate signal") - action_step = ActionStep( - step_number=1, - timing=Timing(start_time=0.0, end_time=1.2), - model_input_messages=[ - ChatMessage( - role=MessageRole.USER, - content=[{"type": "text", "text": "What is the status?"}], - ) - ], - tool_calls=[ - ToolCall( - name="status_tool", - arguments={"region": "EMEA", "threshold": 0.5}, - id="call-1", - ) - ], - model_output="Status retrieved", - action_output={"result": "ok"}, - token_usage=TokenUsage(input_tokens=10, output_tokens=5), - ) - memory.steps.extend([task_step, action_step]) - - succinct_steps = memory.get_succinct_steps() - self.assertEqual(len(succinct_steps), 2) - self.assertNotIn("model_input_messages", succinct_steps[1]) - - full_steps = memory.get_full_steps() - self.assertEqual(len(full_steps), 2) - self.assertIn("model_input_messages", full_steps[1]) - self.assertEqual( - full_steps[1]["tool_calls"][0]["function"]["name"], - "status_tool", - ) - self.assertEqual(full_steps[1]["token_usage"]["total_tokens"], 15) - - def test_action_step_to_messages(self): - step = ActionStep( - step_number=2, - timing=Timing(start_time=5.0, end_time=6.0), - model_output="Calculation complete", - tool_calls=[ - ToolCall( - name="calc_tool", - arguments={"x": 1, "y": 2}, - id="calc-1", - ) - ], - observations="All good", - ) - - messages = step.to_messages() - self.assertEqual(messages[0].role, MessageRole.ASSISTANT) - self.assertEqual(messages[0].content[0]["text"], "Calculation complete") - self.assertEqual(messages[1].role, MessageRole.TOOL_CALL) - self.assertIn("Calling tools", messages[1].content[0]["text"]) - self.assertEqual(messages[-1].role, MessageRole.TOOL_RESPONSE) - self.assertIn("Observation", messages[-1].content[0]["text"]) - - def test_callback_registry_executes_registered_callbacks(self): - registry = CallbackRegistry() - captured = [] - - def on_action(step, agent=None): - captured.append((step.step_number, agent)) - - registry.register(ActionStep, on_action) - - dummy_step = ActionStep( - step_number=3, timing=Timing(start_time=0.0, end_time=0.1) - ) - registry.callback(dummy_step, agent="agent-instance") - - self.assertEqual(len(captured), 1) - self.assertEqual(captured[0][0], 3) - self.assertEqual(captured[0][1], "agent-instance") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/models/test_memory_models.py b/tests/models/test_memory_models.py deleted file mode 100644 index 35156b9..0000000 --- a/tests/models/test_memory_models.py +++ /dev/null @@ -1,108 +0,0 @@ -import unittest - -from quantmind.models.memory import ( - ActionStep, - PlanningStep, - TaskStep, - ToolCall, -) -from quantmind.models.messages import ChatMessage, MessageRole -from quantmind.utils.monitoring import Timing, TokenUsage - - -class MemoryModelsTestCase(unittest.TestCase): - """Test memory models functionality.""" - - def test_action_step_dict_serializes_tool_calls_and_tokens(self): - call = ToolCall( - name="status_tool", - arguments={"region": "APAC"}, - id="call-1", - ) - step = ActionStep( - step_number=1, - timing=Timing(start_time=0.0, end_time=0.4), - model_input_messages=[ - ChatMessage( - role=MessageRole.USER, - content=[{"type": "text", "text": "Check signals"}], - ) - ], - tool_calls=[call], - model_output="Signals retrieved", - action_output={"status": "ok"}, - token_usage=TokenUsage(input_tokens=7, output_tokens=3), - ) - - data = step.dict() - - self.assertEqual(data["tool_calls"], [call.dict()]) - self.assertEqual(data["token_usage"]["total_tokens"], 10) - self.assertEqual(data["model_output"], "Signals retrieved") - self.assertIsNone(data["observations"]) - - def test_action_step_to_messages_includes_output_and_observation(self): - step = ActionStep( - step_number=2, - timing=Timing(start_time=1.0, end_time=1.5), - model_output="Computation complete", - observations="Done", - tool_calls=[ - ToolCall( - name="compute", - arguments={"x": 1}, - id="compute-1", - ) - ], - ) - - messages = step.to_messages() - - self.assertEqual(messages[0].role, MessageRole.ASSISTANT) - self.assertEqual(messages[0].content[0]["text"], "Computation complete") - self.assertEqual(messages[1].role, MessageRole.TOOL_CALL) - self.assertIn("Calling tools", messages[1].content[0]["text"]) - self.assertEqual(messages[-1].role, MessageRole.TOOL_RESPONSE) - self.assertIn("Observation", messages[-1].content[0]["text"]) - - def test_planning_step_to_messages(self): - step = PlanningStep( - model_input_messages=[ - ChatMessage( - role=MessageRole.USER, - content=[{"type": "text", "text": "Plan it"}], - ) - ], - model_output_message=ChatMessage( - role=MessageRole.ASSISTANT, - content="Plan follows", - ), - plan="Step 1", - timing=Timing(start_time=2.0, end_time=2.5), - ) - - messages = step.to_messages() - - self.assertEqual(len(messages), 2) - self.assertEqual(messages[0].role, MessageRole.ASSISTANT) - self.assertEqual(messages[1].role, MessageRole.USER) - - def test_task_step_to_messages_handles_images(self): - class _Image: - def __init__(self, value: str): - self._value = value - - def tobytes(self): - return self._value - - fake_image = _Image("image-bytes") - step = TaskStep(task="Summarize", task_images=[fake_image]) - messages = step.to_messages() - - self.assertEqual(messages[0].role, MessageRole.USER) - self.assertEqual(messages[0].content[1]["type"], "image") - self.assertIs(messages[0].content[1]["image"], fake_image) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/models/test_messages_models.py b/tests/models/test_messages_models.py deleted file mode 100644 index 3e98bc2..0000000 --- a/tests/models/test_messages_models.py +++ /dev/null @@ -1,104 +0,0 @@ -import unittest - -from quantmind.models.messages import ( - ChatMessage, - ChatMessageStreamDelta, - ChatMessageToolCall, - ChatMessageToolCallFunction, - ChatMessageToolCallStreamDelta, - MessageRole, - agglomerate_stream_deltas, - get_clean_message_list, -) -from quantmind.utils.monitoring import TokenUsage - - -class MessagesModelsTestCase(unittest.TestCase): - """Test messages models functionality.""" - - def test_chat_message_from_dict_parses_tool_calls(self): - data = { - "role": MessageRole.ASSISTANT, - "content": "Computation done", - "tool_calls": [ - { - "function": {"name": "compute", "arguments": {"x": 1}}, - "id": "call-1", - "type": "function", - } - ], - } - msg = ChatMessage.from_dict(data) - - self.assertIsInstance(msg.tool_calls[0], ChatMessageToolCall) - self.assertEqual(msg.tool_calls[0].function.name, "compute") - self.assertEqual(msg.tool_calls[0].function.arguments, {"x": 1}) - - def test_agglomerate_stream_deltas_merges_content_and_tokens(self): - deltas = [ - ChatMessageStreamDelta( - content="First chunk ", - token_usage=TokenUsage(input_tokens=1, output_tokens=2), - ), - ChatMessageStreamDelta( - content="second chunk", - token_usage=TokenUsage(input_tokens=3, output_tokens=1), - ), - ChatMessageStreamDelta( - tool_calls=[ - ChatMessageToolCallStreamDelta( - index=0, - id="call-2", - type="function", - function=ChatMessageToolCallFunction( - name="analyse", - arguments='{"param": ', - ), - ) - ] - ), - ChatMessageStreamDelta( - tool_calls=[ - ChatMessageToolCallStreamDelta( - index=0, - function=ChatMessageToolCallFunction( - name="", - arguments='"value"}', - ), - ) - ] - ), - ] - - message = agglomerate_stream_deltas(deltas) - - self.assertEqual(message.content, "First chunk second chunk") - self.assertEqual(message.token_usage.total_tokens, 7) - self.assertEqual(len(message.tool_calls), 1) - self.assertEqual(message.tool_calls[0].id, "call-2") - self.assertEqual(message.tool_calls[0].function.name, "analyse") - self.assertEqual( - message.tool_calls[0].function.arguments, '{"param": "value"}' - ) - - def test_get_clean_message_list_merges_consecutive_roles(self): - messages = [ - ChatMessage( - role=MessageRole.USER, - content=[{"type": "text", "text": "Line one"}], - ), - ChatMessage( - role=MessageRole.USER, - content=[{"type": "text", "text": "Line two"}], - ), - ] - - result = get_clean_message_list(messages) - - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["role"], MessageRole.USER) - self.assertEqual(result[0]["content"][0]["text"], "Line one\nLine two") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/storage/test_local_storage.py b/tests/storage/test_local_storage.py deleted file mode 100644 index 98ba4c9..0000000 --- a/tests/storage/test_local_storage.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Tests for enhanced storage functionality with efficient indexing.""" - -import json -import shutil -import tempfile -import unittest -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import Mock, patch - -from quantmind.config.storage import LocalStorageConfig -from quantmind.models.paper import Paper -from quantmind.storage.local_storage import LocalStorage - - -class TestEnhancedStorageWithIndexing(unittest.TestCase): - """Test enhanced storage functionality with efficient indexing.""" - - def setUp(self): - """Set up test environment.""" - self.temp_dir = Path(tempfile.mkdtemp()) - self.config = LocalStorageConfig( - storage_dir=self.temp_dir, download_timeout=1 - ) - self.storage = LocalStorage(self.config) - - def tearDown(self): - """Clean up test environment.""" - if self.temp_dir.exists(): - shutil.rmtree(self.temp_dir) - - def test_index_initialization(self): - """Test that indexes are properly initialized.""" - # Check that index files are created - self.assertTrue(self.storage._get_index_path("raw_files").exists()) - self.assertTrue(self.storage._get_index_path("knowledges").exists()) - self.assertTrue(self.storage._get_index_path("embeddings").exists()) - - # Check that indexes are empty initially - self.assertEqual(len(self.storage._raw_files_index), 0) - self.assertEqual(len(self.storage._knowledges_index), 0) - self.assertEqual(len(self.storage._embeddings_index), 0) - - def test_raw_file_indexing(self): - """Test raw file storage and indexing.""" - # Store a raw file - pdf_content = b"%PDF-1.4 test content" - file_path = self.storage.store_raw_file( - file_id="test_pdf", content=pdf_content, file_extension=".pdf" - ) - - # Check that index was updated - self.assertIn("test_pdf", self.storage._raw_files_index) - index_entry = self.storage._raw_files_index["test_pdf"] - self.assertEqual(index_entry["extension"], ".pdf") - - # Check that index file was saved - index_path = self.storage._get_index_path("raw_files") - self.assertTrue(index_path.exists()) - - with open(index_path, "r") as f: - saved_index = json.load(f) - self.assertIn("test_pdf", saved_index) - - def test_fast_raw_file_lookup(self): - """Test that raw file lookup uses index for fast retrieval.""" - # Store multiple files - for i in range(5): - content = f"test content {i}".encode() - self.storage.store_raw_file( - file_id=f"test_{i}", content=content, file_extension=".txt" - ) - - # Verify all files are in index - self.assertEqual(len(self.storage._raw_files_index), 5) - - # Test retrieval - should use index - retrieved_path = self.storage.get_raw_file("test_3") - self.assertIsNotNone(retrieved_path) - self.assertTrue(retrieved_path.exists()) - self.assertEqual(retrieved_path.suffix, ".txt") - - def test_knowledge_indexing(self): - """Test knowledge item storage and indexing.""" - paper = Paper( - title="Test Paper", - abstract="Test abstract", - authors=["Test Author"], - arxiv_id="test.001", - categories=["q-fin.CP"], - published_date=datetime.now(timezone.utc), - source="test", - ) - - # Store knowledge - paper_id = self.storage.store_knowledge(paper) - - # Check that index was updated - self.assertIn("test.001", self.storage._knowledges_index) - - # Check fast retrieval - retrieved_paper = self.storage.get_knowledge("test.001") - self.assertIsNotNone(retrieved_paper) - self.assertEqual(retrieved_paper.title, "Test Paper") - - def test_embedding_indexing(self): - """Test embedding storage and indexing.""" - embedding = [0.1, 0.2, 0.3, 0.4, 0.5] - - # Store embedding - self.storage.store_embedding("test_knowledge", embedding, "test_model") - - # Check that index was updated - self.assertIn("test_knowledge", self.storage._embeddings_index) - - # Check fast retrieval - retrieved_embedding = self.storage.get_embedding("test_knowledge") - self.assertIsNotNone(retrieved_embedding) - self.assertEqual(retrieved_embedding["embedding"], embedding) - self.assertEqual(retrieved_embedding["model"], "test_model") - - def test_index_persistence_across_restarts(self): - """Test that indexes persist across storage restarts.""" - # Store some data - pdf_content = b"test pdf" - self.storage.store_raw_file( - "test_pdf", content=pdf_content, file_extension=".pdf" - ) - - paper = Paper( - title="Test Paper", - abstract="Test abstract", - authors=["Test Author"], - arxiv_id="test.001", - source="test", - ) - self.storage.store_knowledge(paper) - - # Create new storage instance (simulating restart) - new_storage = LocalStorage(self.config) - - # Check that indexes were loaded - self.assertIn("test_pdf", new_storage._raw_files_index) - self.assertIn("test.001", new_storage._knowledges_index) - - # Check that retrieval still works - retrieved_pdf = new_storage.get_raw_file("test_pdf") - self.assertIsNotNone(retrieved_pdf) - - retrieved_paper = new_storage.get_knowledge("test.001") - self.assertIsNotNone(retrieved_paper) - - def test_index_rebuilding(self): - """Test index rebuilding functionality.""" - # Create files directly in filesystem (bypassing storage) - raw_file = self.config.raw_files_dir / "direct_file.pdf" - raw_file.write_bytes(b"direct pdf content") - - knowledge_file = self.config.knowledges_dir / "direct_knowledge.json" - knowledge_data = { - "id": "direct_knowledge", - "title": "Direct Knowledge", - "abstract": "Direct abstract", - "content_type": "generic", - "source": "direct", - } - knowledge_file.write_text(json.dumps(knowledge_data)) - - # Rebuild indexes - self.storage.rebuild_all_indexes() - - # Check that files were indexed - self.assertIn("direct_file", self.storage._raw_files_index) - self.assertIn("direct_knowledge", self.storage._knowledges_index) - - # Check retrieval works - retrieved_raw = self.storage.get_raw_file("direct_file") - self.assertIsNotNone(retrieved_raw) - - retrieved_knowledge = self.storage.get_knowledge("direct_knowledge") - self.assertIsNotNone(retrieved_knowledge) - - def test_index_cleanup_on_missing_files(self): - """Test that index is cleaned up when files are deleted externally.""" - # Store a file - self.storage.store_raw_file( - "test_file", content=b"test", file_extension=".txt" - ) - - # Verify it's in index - self.assertIn("test_file", self.storage._raw_files_index) - - # Delete file directly from filesystem - file_path = self.storage.get_raw_file("test_file") - file_path.unlink() - - # Try to retrieve - should clean up index - retrieved = self.storage.get_raw_file("test_file") - self.assertIsNone(retrieved) - - # Check that index was cleaned up - self.assertNotIn("test_file", self.storage._raw_files_index) - - def test_fallback_to_directory_scan(self): - """Test fallback to directory scan when file not in index.""" - # Create file directly in filesystem - raw_file = self.config.raw_files_dir / "fallback_test.pdf" - raw_file.write_bytes(b"fallback content") - - # File should not be in index initially - self.assertNotIn("fallback_test", self.storage._raw_files_index) - - # Try to retrieve - should find via directory scan and add to index - retrieved = self.storage.get_raw_file("fallback_test") - self.assertIsNotNone(retrieved) - - # Check that it was added to index - self.assertIn("fallback_test", self.storage._raw_files_index) - - def test_delete_operations_update_index(self): - """Test that delete operations properly update indexes.""" - # Store and then delete raw file - self.storage.store_raw_file( - "delete_test", content=b"test", file_extension=".txt" - ) - self.assertIn("delete_test", self.storage._raw_files_index) - - deleted = self.storage.delete_raw_file("delete_test") - self.assertTrue(deleted) - self.assertNotIn("delete_test", self.storage._raw_files_index) - - # Store and then delete knowledge - paper = Paper( - title="Delete Test", - abstract="Test", - arxiv_id="delete.001", - source="test", - ) - self.storage.store_knowledge(paper) - self.assertIn("delete.001", self.storage._knowledges_index) - - deleted = self.storage.delete_knowledge("delete.001") - self.assertTrue(deleted) - self.assertNotIn("delete.001", self.storage._knowledges_index) - - def test_get_all_knowledges_uses_index(self): - """Test that get_all_knowledges uses index for efficient iteration.""" - # Store multiple knowledge items - for i in range(3): - paper = Paper( - title=f"Paper {i}", - abstract=f"Abstract {i}", - arxiv_id=f"test.{i:03d}", - source="test", - ) - self.storage.store_knowledge(paper) - - # Get all knowledges - all_knowledges = list(self.storage.get_all_knowledges()) - - # Should have 3 items - self.assertEqual(len(all_knowledges), 3) - - # Check that we got the right items - titles = [k.title for k in all_knowledges] - self.assertIn("Paper 0", titles) - self.assertIn("Paper 1", titles) - self.assertIn("Paper 2", titles) - - def test_storage_info_includes_index_stats(self): - """Test that storage info includes index statistics.""" - # Store some test data - self.storage.store_raw_file( - "test_file", content=b"test", file_extension=".txt" - ) - - paper = Paper( - title="Test Paper", - abstract="Test", - arxiv_id="test.001", - source="test", - ) - self.storage.store_knowledge(paper) - - self.storage.store_embedding("test.001", [0.1, 0.2], "test_model") - - # Get storage info - info = self.storage.get_storage_info() - - # Check that index stats are included - self.assertIn("indexes", info) - indexes = info["indexes"] - - self.assertEqual(indexes["raw_files"]["entries"], 1) - self.assertEqual(indexes["knowledges"]["entries"], 1) - self.assertEqual(indexes["embeddings"]["entries"], 1) - - # Check that index file paths are included - self.assertIn("index_file", indexes["raw_files"]) - self.assertIn("index_file", indexes["knowledges"]) - self.assertIn("index_file", indexes["embeddings"]) - - def test_process_knowledge_paper(self): - """Test specialized Paper storage with indexing.""" - paper = Paper( - title="Test Paper", - abstract="Test abstract for paper", - authors=["Test Author"], - arxiv_id="test.001", - categories=["q-fin.CP"], - published_date=datetime.now(timezone.utc), - source="test", - ) - - # Store using specialized method - paper_id = self.storage.process_knowledge(paper) - - # Verify paper was stored and indexed - self.assertEqual(paper_id, "test.001") - self.assertIn("test.001", self.storage._knowledges_index) - - # Verify paper can be retrieved quickly - retrieved_paper = self.storage.get_knowledge(paper_id) - self.assertIsNotNone(retrieved_paper) - self.assertEqual(retrieved_paper.title, "Test Paper") - - @patch("requests.get") - def test_process_knowledge_paper_with_pdf_url(self, mock_requests): - """Test Paper storage with PDF URL and indexing.""" - # Mock requests to avoid real network calls - mock_response = Mock() - mock_response.content = b"%PDF-1.4 fake content" - mock_response.raise_for_status = Mock() - mock_requests.return_value = mock_response - - paper = Paper( - title="Paper with PDF URL", - abstract="Test paper with PDF URL", - authors=["Test Author"], - arxiv_id="test.002", - pdf_url="https://example.com/paper.pdf", - categories=["q-fin.CP"], - published_date=datetime.now(timezone.utc), - source="test", - ) - - # Store using specialized method - paper_id = self.storage.process_knowledge(paper) - - # Verify paper was stored and indexed - self.assertEqual(paper_id, "test.002") - self.assertIn("test.002", self.storage._knowledges_index) - - retrieved_paper = self.storage.get_knowledge(paper_id) - self.assertIsNotNone(retrieved_paper) - self.assertEqual( - retrieved_paper.pdf_url, "https://example.com/paper.pdf" - ) - - def test_store_raw_file_with_content(self): - """Test storing raw file from content bytes with indexing.""" - # Test PDF content - pdf_content = b"%PDF-1.4 test content" - - file_path = self.storage.store_raw_file( - file_id="test_pdf", content=pdf_content, file_extension=".pdf" - ) - - # Verify file was created and indexed - stored_path = Path(file_path) - self.assertTrue(stored_path.exists()) - self.assertEqual(stored_path.suffix, ".pdf") - self.assertIn("test_pdf", self.storage._raw_files_index) - - # Verify content - with open(stored_path, "rb") as f: - self.assertEqual(f.read(), pdf_content) - - def test_store_raw_file_validation(self): - """Test input validation for store_raw_file.""" - # Test missing both parameters - with self.assertRaises(ValueError): - self.storage.store_raw_file("test_id") - - # Test providing both parameters - with self.assertRaises(ValueError): - self.storage.store_raw_file( - "test_id", file_path=Path("dummy"), content=b"dummy" - ) - - def test_store_raw_file_backward_compatibility(self): - """Test that file copying still works with indexing.""" - # Create a temporary file to copy - temp_file = self.temp_dir / "source.txt" - temp_file.write_text("Source file content") - - # Store by copying - file_path = self.storage.store_raw_file( - file_id="copied_file", file_path=temp_file - ) - - # Verify file was copied and indexed - stored_path = Path(file_path) - self.assertTrue(stored_path.exists()) - self.assertEqual(stored_path.read_text(), "Source file content") - self.assertIn("copied_file", self.storage._raw_files_index) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tagger/test_llm_tagger.py b/tests/tagger/test_llm_tagger.py deleted file mode 100644 index 61fe1d1..0000000 --- a/tests/tagger/test_llm_tagger.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Unit tests for simplified LLM tagger.""" - -import json -import unittest -from unittest.mock import Mock, patch - -from quantmind.config import LLMTaggerConfig -from quantmind.models.paper import Paper -from quantmind.tagger.llm_tagger import LLMTagger - - -class TestLLMTagger(unittest.TestCase): - """Test cases for simplified LLM tagger.""" - - def setUp(self): - """Set up test fixtures.""" - self.sample_paper = Paper( - title="Deep Learning for Cryptocurrency Trading", - abstract="This paper presents LSTM networks for Bitcoin price prediction using sentiment analysis.", - authors=["John Doe", "Jane Smith"], - url="https://example.com/paper.pdf", - ) - - def test_tagger_initialization(self): - """Test tagger initialization with default parameters.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - - self.assertEqual(tagger.llm_type, "openai") - self.assertEqual(tagger.llm_name, "gpt-4o") - self.assertEqual(tagger.config.max_tags, 5) - self.assertEqual(tagger.config.llm_config.temperature, 0.0) - - def test_tagger_initialization_with_params(self): - """Test tagger initialization with custom parameters.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger( - config=LLMTaggerConfig.create( - model="gpt-3.5-turbo", - temperature=0.7, - max_tags=3, - custom_instructions="Custom instructions for tagging", - ) - ) - - self.assertEqual(tagger.llm_name, "gpt-3.5-turbo") - self.assertEqual(tagger.config.max_tags, 3) - self.assertEqual(tagger.config.llm_config.temperature, 0.7) - self.assertEqual( - tagger.config.llm_config.custom_instructions, - "Custom instructions for tagging", - ) - - def test_tagger_initialization_with_direct_config(self): - """Test tagger initialization with direct config creation.""" - from quantmind.config.llm import LLMConfig - - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - llm_config = LLMConfig( - model="claude-3-5-sonnet-20241022", - temperature=0.5, - max_tokens=3000, - api_key="test-key", - ) - - tagger_config = LLMTaggerConfig( - llm_config=llm_config, - max_tags=7, - custom_prompt="Analyze content: {content} and return {max_tags} tags", - ) - - tagger = LLMTagger(config=tagger_config) - - self.assertEqual(tagger.llm_name, "claude-3-5-sonnet-20241022") - self.assertEqual(tagger.config.max_tags, 7) - self.assertEqual(tagger.config.llm_config.temperature, 0.5) - self.assertEqual(tagger.config.llm_config.max_tokens, 3000) - self.assertEqual(tagger.config.llm_config.api_key, "test-key") - - def test_prepare_content(self): - """Test content preparation from paper.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - content = tagger._prepare_content(self.sample_paper) - - self.assertIn( - "Title: Deep Learning for Cryptocurrency Trading", content - ) - self.assertIn("Abstract: This paper presents LSTM", content) - - def test_build_default_prompt(self): - """Test default prompt construction.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - content = "Test content" - - prompt = tagger._build_prompt(content) - - self.assertIn("quantitative finance", prompt) - self.assertIn("Test content", prompt) - self.assertIn("5 relevant tags", prompt) - self.assertIn("JSON list", prompt) - - def test_build_prompt_with_custom_prompt(self): - """Test prompt construction with custom prompt.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger( - config=LLMTaggerConfig( - custom_prompt="Analyze the content and return 5 relevant tags: {content}" - ) - ) - content = "Test content" - - prompt = tagger._build_prompt(content) - - self.assertIn( - "Analyze the content and return 5 relevant tags", prompt - ) - self.assertIn("Test content", prompt) - - def test_build_custom_prompt_with_variables(self): - """Test custom prompt construction with variables.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - custom_prompt = "Analyze: {content} and return {max_tags} tags" - tagger = LLMTagger( - config=LLMTaggerConfig(custom_prompt=custom_prompt) - ) - content = "Test content" - - prompt = tagger._build_prompt(content) - - self.assertEqual(prompt, "Analyze: Test content and return 5 tags") - - def test_parse_tags_json(self): - """Test parsing tags from JSON response.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - response = ( - '["crypto", "machine learning", "lstm", "bitcoin", "trading"]' - ) - - tags = tagger._parse_tags(response) - - self.assertEqual(len(tags), 5) - self.assertIn("crypto", tags) - self.assertIn("machine learning", tags) - - def test_parse_tags_json_with_extra_text(self): - """Test parsing tags from JSON response with extra text.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - response = 'Here are the tags: ["crypto", "deep learning", "sentiment"] for this paper.' - - tags = tagger._parse_tags(response) - - self.assertEqual(len(tags), 3) - self.assertIn("crypto", tags) - self.assertIn("deep learning", tags) - - def test_parse_tags_fallback(self): - """Test fallback tag parsing from plain text.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - response = ( - '"crypto", "machine learning", "trading", "sentiment analysis"' - ) - - tags = tagger._parse_tags(response) - - self.assertTrue(len(tags) > 0) - self.assertIn("crypto", tags) - - def test_tag_paper_success(self): - """Test successful paper tagging.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - # Mock LLMBlock - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - # Mock LLM response - mock_llm_block.generate_text.return_value = ( - '["crypto", "lstm", "trading", "deep learning", "bitcoin"]' - ) - - tagger = LLMTagger() - result_paper = tagger.tag_paper(self.sample_paper) - - # Check that tags were added - self.assertTrue(len(result_paper.tags) > 0) - self.assertIn("crypto", result_paper.tags) - self.assertIn("llm_tagger", result_paper.meta_info["tagger"]) - - def test_tag_paper_no_llm_block(self): - """Test paper tagging when no LLM block is available.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_create.return_value = None - - tagger = LLMTagger() - tagger.llm_block = None - - result_paper = tagger.tag_paper(self.sample_paper) - - # Paper should be returned unchanged - self.assertEqual(result_paper.title, self.sample_paper.title) - self.assertEqual(len(result_paper.tags), 0) - - def test_extract_tags(self): - """Test tag extraction from arbitrary text.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - # Mock LLMBlock - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - # Mock LLM response - mock_llm_block.generate_text.return_value = ( - '["finance", "analysis", "data"]' - ) - - # Configure tagger to expect 3 tags - config = LLMTaggerConfig.create(max_tags=3) - tagger = LLMTagger(config=config) - - tags = tagger.extract_tags( - "Financial data analysis paper", "Finance Title" - ) - - self.assertEqual(len(tags), 3) - self.assertIn("finance", tags) - - def test_extract_tags_from_text_quoted(self): - """Test extracting tags from text with quoted items.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - text = 'The tags are "machine learning", "trading", "analysis"' - - tags = tagger._extract_tags_from_text(text) - - self.assertEqual(len(tags), 3) - self.assertIn("machine learning", tags) - self.assertIn("trading", tags) - - def test_extract_tags_from_text_comma_separated(self): - """Test extracting tags from comma-separated text.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger() - text = "machine learning, deep learning, trading algorithms, risk management" - - tags = tagger._extract_tags_from_text(text) - - self.assertTrue(len(tags) >= 3) - self.assertIn("machine learning", tags) - - def test_max_tags_limit(self): - """Test that tag count is limited to max_tags.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - tagger = LLMTagger(config=LLMTaggerConfig(max_tags=3)) - response = '["tag1", "tag2", "tag3", "tag4", "tag5"]' - - tags = tagger._parse_tags(response) - limited_tags = tags[: tagger.config.max_tags] - - self.assertEqual(len(limited_tags), 3) - - def test_llm_config_access(self): - """Test accessing LLM configuration through composition.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - config = LLMTaggerConfig.create( - model="gpt-4o-mini", - temperature=0.8, - max_tokens=2000, - api_key="test-api-key", - max_tags=8, - ) - - tagger = LLMTagger(config=config) - - # Test access to LLM config properties - self.assertEqual(tagger.config.llm_config.model, "gpt-4o-mini") - self.assertEqual(tagger.config.llm_config.temperature, 0.8) - self.assertEqual(tagger.config.llm_config.max_tokens, 2000) - self.assertEqual(tagger.config.llm_config.api_key, "test-api-key") - self.assertEqual(tagger.config.max_tags, 8) - - def test_provider_detection(self): - """Test LLM provider type detection.""" - with patch( - "quantmind.tagger.llm_tagger.create_llm_block" - ) as mock_create: - mock_llm_block = Mock() - mock_create.return_value = mock_llm_block - - # Test OpenAI - config = LLMTaggerConfig.create(model="gpt-4o") - tagger = LLMTagger(config=config) - self.assertEqual( - tagger.config.llm_config.get_provider_type(), "openai" - ) - - # Test Anthropic - config = LLMTaggerConfig.create(model="claude-3-5-sonnet-20241022") - tagger = LLMTagger(config=config) - self.assertEqual( - tagger.config.llm_config.get_provider_type(), "anthropic" - ) - - # Test Google - config = LLMTaggerConfig.create(model="gemini-1.5-pro") - tagger = LLMTagger(config=config) - self.assertEqual( - tagger.config.llm_config.get_provider_type(), "google" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tools/test_base_tool.py b/tests/tools/test_base_tool.py deleted file mode 100644 index 316497c..0000000 --- a/tests/tools/test_base_tool.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Sanity checks for the QuantMind tool decorator and helpers.""" - -import unittest - -from quantmind.tools._function_type_hints_utils import DocstringParsingException -from quantmind.tools.base import Tool, tool, validate_tool_arguments - - -@tool -def multiply(x: float, y: float) -> float: - """Multiply two numbers. - - Args: - x (float): Left operand. - y (float): Right operand. - - Returns: - float: Product of the two inputs. - """ - return x * y - - -class UppercaseTool(Tool): - """Simple concrete Tool subclass for serialization checks.""" - - name = "uppercase_tool" - description = "Uppercase string input." - inputs = { - "text": { - "type": "string", - "description": "Text to uppercase.", - } - } - output_type = "string" - - def forward(self, text: str) -> str: # noqa: D401 - self explanatory - return text.upper() - - -class StructuredTool(Tool): - """Tool returning structured output with an explicit schema.""" - - name = "structured_tool" - description = "Return the length of a string in a structured payload." - inputs = { - "text": { - "type": "string", - "description": "Input text to measure.", - } - } - output_type = "object" - output_schema = { - "type": "object", - "properties": { - "length": {"type": "integer"}, - }, - "required": ["length"], - } - - def forward(self, text: str) -> dict[str, int]: # noqa: D401 - return {"length": len(text)} - - -class ToolDecoratorTests(unittest.TestCase): - """Validate the lightweight tool decorator behaviour.""" - - def test_tool_requires_docstring(self) -> None: - """Functions without docstrings should be rejected.""" - - def missing_doc(a: int) -> int: - return a - - with self.assertRaises(DocstringParsingException): - tool(missing_doc) - - def test_tool_metadata_and_execution(self) -> None: - """Decorated tools expose schema metadata and execute correctly.""" - - @tool - def add(a: int, b: int) -> int: - """Add two integers. - - Args: - a (int): First operand. - b (int): Second operand. - - Returns: - int: Computed sum. - """ - return a + b - - self.assertEqual(add.description.strip(), "Add two integers.") - self.assertEqual(add.inputs["a"]["type"], "integer") - - validate_tool_arguments(add, {"a": 1, "b": 2}) - with self.assertRaises(ValueError): - validate_tool_arguments(add, {"a": 1}) - with self.assertRaises(TypeError): - validate_tool_arguments(add, {"a": "one", "b": 2}) - - self.assertEqual(add(a=3, b=4), 7) - self.assertEqual(add({"a": 5, "b": 6}), 11) - self.assertIsInstance(add, Tool) - - def test_optional_and_enum_inputs(self) -> None: - """Defaults impact required flag and enum choices validated.""" - - @tool - def choose_action(action: str, mode: str = "auto") -> str: - """Select an action. - - Args: - action (str): Action flag (choices: ["buy", "sell"]) - mode (str): Execution mode. - """ - return f"{mode}:{action}" - - self.assertEqual( - choose_action.inputs["action"]["enum"], ["buy", "sell"] - ) - - validate_tool_arguments(choose_action, {"action": "buy"}) - with self.assertRaises(ValueError): - validate_tool_arguments(choose_action, {"mode": "manual"}) - - def test_positional_invocation_rules(self) -> None: - """Only single-argument tools allow positional calls.""" - - @tool - def echo(text: str) -> str: - """Echo text. - - Args: - text (str): Text to return. - """ - return text - - self.assertEqual(echo("hello"), "hello") - - @tool - def concat(a: str, b: str) -> str: - """Concatenate strings. - - Args: - a (str): First part. - b (str): Second part. - """ - return a + b - - with self.assertRaises(TypeError): - concat("value") - - -class ToolRuntimeTests(unittest.TestCase): - """Cover behaviour of concrete Tool subclasses and serialization helpers.""" - - def test_multiply_tool_schema_and_validation(self) -> None: - """Decorated module-level tool exposes schema metadata and validation.""" - self.assertEqual(multiply.name, "multiply") - self.assertEqual(multiply.inputs["x"]["type"], "number") - - validate_tool_arguments(multiply, {"x": 1, "y": 2.5}) - with self.assertRaises(ValueError): - validate_tool_arguments(multiply, {"x": 1}) - with self.assertRaises(TypeError): - validate_tool_arguments(multiply, {"x": "bad", "y": 1}) - - self.assertEqual(multiply(x=2, y=3), 6) - - def test_structured_tool_prompts_and_call(self) -> None: - """StructuredTool generates descriptive prompts and handles dict inputs.""" - structured = StructuredTool() - code_prompt = structured.to_code_prompt() - self.assertIn("structured_tool", code_prompt) - self.assertIn( - "Important: This tool returns structured output!", code_prompt - ) - - calling_prompt = structured.to_tool_calling_prompt() - self.assertIn("structured_tool", calling_prompt) - self.assertIn("Returns an output of type: object", calling_prompt) - - result = structured({"text": "alpha"}) - self.assertEqual(result, {"length": 5}) - self.assertTrue(structured.is_initialized) - - def test_subclass_to_dict_roundtrip(self) -> None: - """Subclass tools serialize to code and can be rehydrated.""" - tool_instance = UppercaseTool() - tool_dict = tool_instance.to_dict() - - self.assertEqual(tool_dict["name"], "uppercase_tool") - self.assertIn("uppercase_tool", tool_dict["code"]) - self.assertIn("quantmind", tool_dict["requirements"]) - - reloaded = Tool.from_dict(tool_dict) - self.assertEqual(reloaded(text="abc"), "ABC") - - def test_decorated_tool_to_dict_contains_forward_source(self) -> None: - """SimpleTool export includes the wrapped forward definition.""" - exported = multiply.to_dict() - self.assertEqual(exported["name"], "multiply") - self.assertIn("class SimpleTool", exported["code"]) - self.assertIn("def forward(self, x: float, y: float)", exported["code"]) - - def test_invalid_tool_definition_detected_on_init(self) -> None: - """Invalid class attributes should raise during instantiation.""" - - class InvalidNameTool(Tool): - name = "invalid tool name" - description = "Bad name" - inputs = { - "value": { - "type": "string", - "description": "v", - } - } - output_type = "string" - - def forward( - self, value: str - ) -> str: # pragma: no cover - never called - return value - - with self.assertRaises(Exception): - InvalidNameTool() - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/tools/test_function_type_hints_utils.py b/tests/tools/test_function_type_hints_utils.py deleted file mode 100644 index ec0edc4..0000000 --- a/tests/tools/test_function_type_hints_utils.py +++ /dev/null @@ -1,553 +0,0 @@ -# coding=utf-8 -# Copyright 2024 HuggingFace Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from typing import Any - -import pytest - -from quantmind.tools._function_type_hints_utils import ( - DocstringParsingException, - get_imports, - get_json_schema, -) - - -@pytest.fixture -def valid_func(): - """A well-formed function with docstring, type hints, and return block.""" - - def multiply(x: int, y: float) -> float: - """Multiplies two numbers. - - Args: - x: The first number. - y: The second number. - - Returns: - Product of x and y. - """ - return x * y - - return multiply - - -@pytest.fixture -def no_docstring_func(): - """Function with no docstring.""" - - def sample(x: int): - return x - - return sample - - -@pytest.fixture -def missing_arg_doc_func(): - """Function with docstring but missing an argument description.""" - - def add(x: int, y: int): # noqa: D417 - """Adds two numbers. - - Args: - x: The first number. - """ - return x + y - - return add - - -@pytest.fixture -def bad_return_func(): - """Function docstring with missing return description (allowed).""" - - def do_nothing(x: str | None = None): - """Does nothing. - - Args: - x: Some optional string. - """ - pass - - return do_nothing - - -@pytest.fixture -def complex_types_func(): # noqa: D103 - def process_data( - items: list[str], config: dict[str, float], point: tuple[int, int] - ) -> dict: - """Process some data. - - Args: - items: List of items to process. - config: Configuration parameters. - point: A position as (x,y). - - Returns: - Processed data result. - """ - return {"result": True} - - return process_data - - -@pytest.fixture -def optional_types_func(): # noqa: D103 - def process_with_optional( - required_arg: str, optional_arg: int | None = None - ) -> str: - """Process with optional argument. - - Args: - required_arg: A required string argument. - optional_arg: An optional integer argument. - - Returns: - Processing result. - """ - return "processed" - - return process_with_optional - - -@pytest.fixture -def enum_choices_func(): # noqa: D103 - def select_color(color: str) -> str: - """Select a color. - - Args: - color: The color to select (choices: ["red", "green", "blue"]) - - Returns: - Selected color. - """ - return color - - return select_color - - -@pytest.fixture -def union_types_func(): # noqa: D103 - def process_union(value: int | str) -> bool | str: - """Process a value that can be either int or string. - - Args: - value: An integer or string value. - - Returns: - Processing result. - """ - return True if isinstance(value, int) else "string result" - - return process_union - - -@pytest.fixture -def nested_types_func(): # noqa: D103 - def process_nested_data(data: list[dict[str, Any]]) -> list[str]: - """Process nested data structure. - - Args: - data: List of dictionaries to process. - - Returns: - List of processed results. - """ - return ["result"] - - return process_nested_data - - -@pytest.fixture -def typed_docstring_func(): # noqa: D103 - def calculate(x: int, y: float) -> float: - """Calculate something. - - Args: - x (int): An integer parameter with type in docstring. - y (float): A float parameter with type in docstring. - - Returns: - float: The calculated result. - """ - return x * y - - return calculate - - -@pytest.fixture -def mismatched_types_func(): # noqa: D103 - def convert(value: int) -> str: - """Convert a value. - - Args: - value (str): A string value (type mismatch with hint). - - Returns: - int: Converted value (type mismatch with hint). - """ - return str(value) - - return convert - - -@pytest.fixture -def complex_docstring_types_func(): # noqa: D103 - def process(data: dict[str, list[int]]) -> list[dict[str, Any]]: - """Process complex data. - - Args: - data (Dict[str, List[int]]): Nested structure with types. - - Returns: - List[Dict[str, Any]]: Processed results with types. - """ - return [{"result": sum(v) for k, v in data.items()}] - - return process - - -@pytest.fixture -def keywords_in_description_func(): # noqa: D103 - def process(value: str) -> str: - """Function with Args: or Returns: keywords in its description. - - Args: - value: A string value. - - Returns: - str: Processed value. - """ - return value.upper() - - return process - - -class TestGetJsonSchema: - """Test for get_json_schema function.""" - - def test_get_json_schema_example(self): - """Test for get_json_schema function.""" - - def fn(x: int, y: tuple[str, str, float] | None = None) -> None: - """Test function. - - Args: - x: The first input - y: The second input - """ - pass - - schema = get_json_schema(fn) - expected_schema = { - "name": "fn", - "description": "Test function.", - "parameters": { - "type": "object", - "properties": { - "x": {"type": "integer", "description": "The first input"}, - "y": { - "type": "array", - "description": "The second input", - "nullable": True, - "prefixItems": [ - {"type": "string"}, - {"type": "string"}, - {"type": "number"}, - ], - }, - }, - "required": ["x"], - }, - "return": {"type": "null"}, - } - assert ( - schema["function"]["parameters"]["properties"]["y"] - == expected_schema["parameters"]["properties"]["y"] - ) - assert schema["function"] == expected_schema - - @pytest.mark.parametrize( - "fixture_name,should_fail", - [ - ("valid_func", False), - # ('no_docstring_func', True), - # ('missing_arg_doc_func', True), - ("bad_return_func", False), - ], - ) - def test_get_json_schema(self, request, fixture_name, should_fail): - func = request.getfixturevalue(fixture_name) - schema = get_json_schema(func) - assert schema["type"] == "function" - assert "function" in schema - assert "parameters" in schema["function"] - - @pytest.mark.parametrize( - "fixture_name,should_fail", - [ - # ('valid_func', False), - ("no_docstring_func", True), - ("missing_arg_doc_func", True), - # ('bad_return_func', False), - ], - ) - def test_get_json_schema_raises(self, request, fixture_name, should_fail): - func = request.getfixturevalue(fixture_name) - with pytest.raises(DocstringParsingException): - get_json_schema(func) - - @pytest.mark.parametrize( - "fixture_name,expected_properties", - [ - ("valid_func", {"x": "integer", "y": "number"}), - ("bad_return_func", {"x": "string"}), - ], - ) - def test_property_types(self, request, fixture_name, expected_properties): - """Test that property types are correctly mapped.""" - func = request.getfixturevalue(fixture_name) - schema = get_json_schema(func) - - properties = schema["function"]["parameters"]["properties"] - for prop_name, expected_type in expected_properties.items(): - assert properties[prop_name]["type"] == expected_type - - def test_schema_basic_structure(self, valid_func): - """Test that basic schema structure is correct.""" - schema = get_json_schema(valid_func) - # Check schema type - assert schema["type"] == "function" - assert "function" in schema - # Check function schema - function_schema = schema["function"] - assert function_schema["name"] == "multiply" - assert "description" in function_schema - assert function_schema["description"] == "Multiplies two numbers." - # Check parameters schema - assert "parameters" in function_schema - params = function_schema["parameters"] - assert params["type"] == "object" - assert "properties" in params - assert "required" in params - assert set(params["required"]) == {"x", "y"} - properties = params["properties"] - assert properties["x"]["type"] == "integer" - assert properties["y"]["type"] == "number" - # Check return schema - assert "return" in function_schema - return_schema = function_schema["return"] - assert return_schema["type"] == "number" - assert return_schema["description"] == "Product of x and y." - - def test_complex_types(self, complex_types_func): - """Test schema generation for complex types.""" - schema = get_json_schema(complex_types_func) - properties = schema["function"]["parameters"]["properties"] - # Check list type - assert properties["items"]["type"] == "array" - # Check dict type - assert properties["config"]["type"] == "object" - # Check tuple type - assert properties["point"]["type"] == "array" - assert len(properties["point"]["prefixItems"]) == 2 - assert properties["point"]["prefixItems"][0]["type"] == "integer" - assert properties["point"]["prefixItems"][1]["type"] == "integer" - - def test_optional_types(self, optional_types_func): - """Test schema generation for optional arguments.""" - schema = get_json_schema(optional_types_func) - params = schema["function"]["parameters"] - # Required argument should be in required list - assert "required_arg" in params["required"] - # Optional argument should not be in required list - assert "optional_arg" not in params["required"] - # Optional argument should be nullable - assert params["properties"]["optional_arg"]["nullable"] is True - assert params["properties"]["optional_arg"]["type"] == "integer" - - def test_enum_choices(self, enum_choices_func): - """Test schema generation for enum choices in docstring.""" - schema = get_json_schema(enum_choices_func) - color_prop = schema["function"]["parameters"]["properties"]["color"] - assert "enum" in color_prop - assert color_prop["enum"] == ["red", "green", "blue"] - - def test_union_types(self, union_types_func): - """Test schema generation for union types.""" - schema = get_json_schema(union_types_func) - value_prop = schema["function"]["parameters"]["properties"]["value"] - return_prop = schema["function"]["return"] - # Check union in parameter - assert len(value_prop["type"]) == 2 - # Check union in return type: should be converted to "any" - assert return_prop["type"] == "any" - - def test_nested_types(self, nested_types_func): - """Test schema generation for nested complex types.""" - schema = get_json_schema(nested_types_func) - data_prop = schema["function"]["parameters"]["properties"]["data"] - assert data_prop["type"] == "array" - - def test_typed_docstring_parsing(self, typed_docstring_func): - """Test parsing of docstrings with type annotations.""" - schema = get_json_schema(typed_docstring_func) - # Type hints should take precedence over docstring types - assert ( - schema["function"]["parameters"]["properties"]["x"]["type"] - == "integer" - ) - assert ( - schema["function"]["parameters"]["properties"]["y"]["type"] - == "number" - ) - # Description should be extracted correctly - assert ( - schema["function"]["parameters"]["properties"]["x"]["description"] - == "An integer parameter with type in docstring." - ) - assert ( - schema["function"]["parameters"]["properties"]["y"]["description"] - == "A float parameter with type in docstring." - ) - # Return type and description should be correct - assert schema["function"]["return"]["type"] == "number" - assert ( - schema["function"]["return"]["description"] - == "The calculated result." - ) - - def test_mismatched_docstring_types(self, mismatched_types_func): - """Test that type hints take precedence over docstring types when they conflict.""" - schema = get_json_schema(mismatched_types_func) - # Type hints should take precedence over docstring types - assert ( - schema["function"]["parameters"]["properties"]["value"]["type"] - == "integer" - ) - # Return type from type hint should be used, not docstring - assert schema["function"]["return"]["type"] == "string" - - def test_complex_docstring_types(self, complex_docstring_types_func): - """Test parsing of complex type annotations in docstrings.""" - schema = get_json_schema(complex_docstring_types_func) - # Check that complex nested type is parsed correctly from type hints - data_prop = schema["function"]["parameters"]["properties"]["data"] - assert data_prop["type"] == "object" - # Check return type - return_prop = schema["function"]["return"] - assert return_prop["type"] == "array" - # Description should include the type information from docstring - assert data_prop["description"] == "Nested structure with types." - assert return_prop["description"] == "Processed results with types." - - @pytest.mark.parametrize( - "fixture_name,expected_description", - [ - ( - "typed_docstring_func", - "An integer parameter with type in docstring.", - ), - ("complex_docstring_types_func", "Nested structure with types."), - ], - ) - def test_type_in_description_handling( - self, request, fixture_name, expected_description - ): - """Test that type information in docstrings is preserved in description.""" - func = request.getfixturevalue(fixture_name) - schema = get_json_schema(func) - # First parameter description should contain the expected text - first_param_name = list( - schema["function"]["parameters"]["properties"].keys() - )[0] - assert ( - schema["function"]["parameters"]["properties"][first_param_name][ - "description" - ] - == expected_description - ) - - def test_with_special_words_in_description_func( - self, keywords_in_description_func - ): - schema = get_json_schema(keywords_in_description_func) - assert ( - schema["function"]["description"] - == "Function with Args: or Returns: keywords in its description." - ) - - -class TestGetImport: - """Test for get_imports function.""" - - @pytest.mark.parametrize( - "code, expected", - [ - ( - """ - import numpy - import pandas - """, - ["numpy", "pandas"], - ), - # From imports - ( - """ - from torch import nn - from transformers import AutoModel - """, - ["torch", "transformers"], - ), - # Mixed case with nested imports - ( - """ - import numpy as np - from torch.nn import Linear - import os.path - """, - ["numpy", "torch", "os"], - ), - # Try/except block (should be filtered) - ( - """ - try: - import torch - except ImportError: - pass - import numpy - """, - ["numpy"], - ), - # Flash attention block (should be filtered) - ( - """ - if is_flash_attn_2_available(): - from flash_attn import flash_attn_func - import transformers - """, - ["transformers"], - ), - # Relative imports (should be excluded) - ( - """ - from .utils import helper - from ..models import transformer - """, - [], - ), - ], - ) - def test_get_imports(self, code: str, expected: list[str]): - assert sorted(get_imports(code)) == sorted(expected) diff --git a/tests/tools/test_tool_validation_module.py b/tests/tools/test_tool_validation_module.py deleted file mode 100644 index 7d4ebcf..0000000 --- a/tests/tools/test_tool_validation_module.py +++ /dev/null @@ -1,215 +0,0 @@ -import ast -import unittest -from textwrap import dedent - -from quantmind.tools import Tool -from quantmind.tools._tool_validation import ( - MethodChecker, - validate_tool_attributes, -) - -UNDEFINED_VARIABLE = "undefined" - - -class ValidTool(Tool): - """Valid tool.""" - - name = "valid_tool" - description = "A valid tool" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - simple_attr = "string" - dict_attr = {"key": "value"} - - def __init__(self, optional_param: str = "default") -> None: - super().__init__() - self.param = optional_param - - def forward(self, input: str) -> str: - return input.upper() - - -class InvalidToolName(Tool): - """Invalid tool name.""" - - name = "invalid tool name" - description = "Tool with invalid name" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - - def forward(self, input: str) -> str: - return input - - -class InvalidToolComplexAttrs(Tool): - """Invalid tool complex attributes.""" - - name = "invalid_tool" - description = "Tool with complex class attributes" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - complex_attr = [x for x in range(3)] - - def forward(self, input: str) -> str: - return input - - -class InvalidToolRequiredParams(Tool): - """Invalid tool required parameters.""" - - name = "invalid_tool" - description = "Tool with required params" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - - def __init__(self, required_param: str, kwarg1: int = 1) -> None: - super().__init__() - self.param = required_param - - def forward(self, input: str) -> str: - return input - - -class InvalidToolNonLiteralDefaultParam(Tool): - """Invalid tool non-literal default parameter.""" - - name = "invalid_tool" - description = "Tool with non-literal default parameter value" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - - def __init__(self, default_param: str = UNDEFINED_VARIABLE) -> None: - super().__init__() - self.default_param = default_param - - def forward(self, input: str) -> str: - return input - - -class InvalidToolUndefinedNames(Tool): - """Invalid tool undefined names.""" - - name = "invalid_tool" - description = "Tool with undefined names" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - - def forward(self, input: str) -> str: - return UNDEFINED_VARIABLE - - -class MultipleAssignmentsTool(Tool): - """Multiple assignments tool.""" - - name = "multiple_assignments_tool" - description = "Tool with multiple assignments" - inputs = { - "input": { - "type": "string", - "description": "input payload", - "required": True, - } - } - output_type = "string" - - def forward(self, input: str) -> str: - first, second = "1", "2" - return input + first + second - - -class TestToolValidation(unittest.TestCase): - """Test tool validation.""" - - def test_validate_tool_attributes_valid(self) -> None: - self.assertIsNone(validate_tool_attributes(ValidTool)) - - def test_invalid_tool_name(self) -> None: - with self.assertRaisesRegex( - ValueError, - "Class attribute 'name' must be a valid Python identifier", - ): - validate_tool_attributes(InvalidToolName) - - def test_complex_class_attribute(self) -> None: - with self.assertRaisesRegex( - ValueError, "Complex attributes should be defined in __init__" - ): - validate_tool_attributes(InvalidToolComplexAttrs) - - def test_required_init_parameter(self) -> None: - with self.assertRaisesRegex( - ValueError, "Parameters in __init__ must have default values" - ): - validate_tool_attributes(InvalidToolRequiredParams) - - def test_non_literal_default(self) -> None: - with self.assertRaisesRegex( - ValueError, - "Parameters in __init__ must have literal default values", - ): - validate_tool_attributes(InvalidToolNonLiteralDefaultParam) - - def test_undefined_names(self) -> None: - with self.assertRaisesRegex( - ValueError, "Name 'UNDEFINED_VARIABLE' is undefined" - ): - validate_tool_attributes(InvalidToolUndefinedNames) - - def test_multiple_assignments_allowed(self) -> None: - self.assertIsNone(validate_tool_attributes(MultipleAssignmentsTool)) - - -class TestMethodChecker(unittest.TestCase): - """Test method checker.""" - - def test_multiple_assignments(self) -> None: - source_code = dedent( - """ - def forward(self) -> str: - a, b = "1", "2" - return a + b - """ - ) - method_checker = MethodChecker(set()) - method_checker.visit(ast.parse(source_code)) - self.assertEqual(method_checker.errors, []) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/tools/test_utils_module.py b/tests/tools/test_utils_module.py deleted file mode 100644 index 2bdf031..0000000 --- a/tests/tools/test_utils_module.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Unit tests covering QuantMind tool utility helpers.""" - -import ast -import unittest - -from quantmind.tools import Tool -from quantmind.tools.utils import ( - ImportFinder, - get_source, - instance_to_source, - is_valid_name, -) - - -class DummyTool(Tool): - """Minimal concrete tool used to verify source serialization.""" - - name = "dummy_tool" - description = "Example tool for instance_to_source" - inputs = { - "input": { - "type": "string", - "description": "Payload text", - "required": True, - } - } - output_type = "string" - long_text = "Line one\nLine two" - - def forward(self, input: str) -> str: - return input.upper() - - -class UtilsTests(unittest.TestCase): - """Validate helper behaviours mirroring smolagents utility tests.""" - - def test_import_finder_collects_base_modules(self) -> None: - """Ensure ImportFinder tracks unique top-level package names.""" - code = "import numpy as np\nfrom pandas.core.frame import DataFrame\n" - finder = ImportFinder() - finder.visit(ast.parse(code)) - self.assertEqual(finder.packages, {"numpy", "pandas"}) - - def test_instance_to_source_includes_base_import(self) -> None: - """instance_to_source emits base class import and method body.""" - tool_source = instance_to_source(DummyTool(), base_cls=Tool) - self.assertIn("from quantmind.tools.base import Tool", tool_source) - self.assertIn("class DummyTool(Tool):", tool_source) - self.assertIn("def forward(self, input: str) -> str:", tool_source) - self.assertIn("return input.upper()", tool_source) - - def test_get_source_standard_function(self) -> None: - """Function source is retrieved and dedented by get_source.""" - - def helper(value: int) -> int: - return value + 1 - - expected = "def helper(value: int) -> int:\n return value + 1" - self.assertEqual(get_source(helper), expected) - - def test_get_source_rejects_non_callable(self) -> None: - """get_source raises TypeError for unsupported inputs.""" - with self.assertRaises(TypeError): - get_source(42) # type: ignore[arg-type] - - def test_is_valid_name(self) -> None: - """Names must be valid identifiers and not keywords.""" - self.assertTrue(is_valid_name("valid_name")) - self.assertFalse(is_valid_name("invalid name")) - self.assertFalse(is_valid_name("for")) - - -if __name__ == "__main__": # pragma: no cover - unittest.main()