Repopatch (#31)

* fix: repomix output not being processed by analyze_content node

Fixed issue where repomix repository content was not being uploaded to R2R:
- Updated analyze_content_for_rag_node to check for repomix_output before scraped_content length check
- Fixed repomix content formatting to wrap in pages array as expected by upload_to_r2r_node
- Added proper metadata structure for repository content including URL preservation

The analyzer was returning early with "No new content to process" for git repos because scraped_content is empty for repomix. Now it properly processes repomix_output and formats it for R2R upload.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update scrape status summary to properly report git repository processing

- Add detection for repomix_output and is_git_repo fields
- Include git repository-specific status messages
- Show repository processing method (Repomix) and output size
- Display R2R collection name when available
- Update fallback summary for git repos
- Add unit tests for git repository summary generation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: enhance GitHub URL processing and add unit tests for collection name extraction

- Implemented logic in extract_collection_name to detect GitHub, GitLab, and Bitbucket repository names from URLs.
- Added comprehensive unit tests for extract_collection_name to validate various GitHub URL formats.
- Updated existing tests to reflect correct repository name extraction for GitHub URLs.

This commit improves the handling of repository URLs, ensuring accurate collection name extraction for R2R uploads.

* feat: enhance configuration and documentation for receipt processing system

- Added `max_concurrent_scrapes` setting in `config.yaml` to limit concurrent scraping operations.
- Updated `pyrefly.toml` to include project root in search paths for module resolution.
- Introduced new documentation files for receipt processing, including agent design, code examples, database design, executive summary, implementation guide, and paperless integration.
- Enhanced overall documentation structure for better navigation and clarity.

This commit improves the configuration management and provides comprehensive documentation for the receipt processing system, facilitating easier implementation and understanding of the workflow.

* fix: adjust FirecrawlApp configuration for improved performance

- Reduced timeout from 160 to 60 seconds and max retries from 2 to 1 in firecrawl_discover_urls_node and firecrawl_process_single_url_node for better responsiveness.
- Implemented URL limit of 100 in firecrawl_discover_urls_node to prevent system overload and added logging for URL processing.
- Updated batch scraping concurrency settings in firecrawl_process_single_url_node to dynamically adjust based on batch size, enhancing efficiency.

These changes optimize the Firecrawl integration for more effective URL discovery and processing.

* fix: resolve linting and type checking errors

- Fix line length error in statistics.py by breaking long string
- Replace Union type annotations with modern union syntax in types.py
- Fix module level import ordering in tools.py
- Add string quotes for forward references in arxiv.py and firecrawl.py
- Replace problematic Any type annotations with proper types where possible
- Fix isinstance call with union types using tuple syntax with noqa
- Move runtime imports to TYPE_CHECKING blocks to fix TC001 violations
- Fix typo in CLAUDE.md documentation
- Add codespell ignore for package-lock.json hash strings
- Fix cast() calls to use proper type objects
- Fix callback function to be synchronous instead of awaited
- Add noqa comments for legitimate Any usage in serialization utilities
- Regenerate package-lock.json to resolve integrity issues

* chore: update various configurations and documentation across the project

- Modified .gitignore to exclude unnecessary files and directories.
- Updated .roomodes and CLAUDE.md for improved clarity and organization.
- Adjusted package-lock.json and package.json for dependency management.
- Enhanced pyrefly.toml and pyrightconfig.json for better project configuration.
- Refined settings in .claude/settings.json and .roo/mcp.json for consistency.
- Improved documentation in .roo/rules and examples for better usability.
- Updated multiple test files and configurations to ensure compatibility and clarity.

These changes collectively enhance project organization, configuration management, and documentation quality.

* feat: enhance configuration and error handling in LLM services

- Updated `config.yaml` to improve clarity and organization, including detailed comments on configuration precedence and environment variable overrides.
- Modified LLM client to conditionally remove the temperature parameter for reasoning models, ensuring proper model behavior.
- Adjusted integration tests to reflect status changes from "completed" to "success" for better accuracy in test assertions.
- Enhanced error handling in various tests to ensure robust responses to missing API keys and other edge cases.

These changes collectively improve the configuration management, error handling, and overall clarity of the LLM services.

* feat: add direct reference allowance in Hatch metadata

- Updated `pyproject.toml` to include `allow-direct-references` in Hatch metadata, enhancing package management capabilities.

This change improves the configuration for package references in the project.

* feat: add collection name override functionality in URL processing

- Enhanced `process_url_to_r2r`, `stream_url_to_r2r`, and `process_url_to_r2r_with_streaming` functions to accept an optional `collection_name` parameter for overriding automatic derivation.
- Updated `URLToRAGState` to include `collection_name` for better state management.
- Modified `upload_to_r2r_node` to utilize the override collection name when provided.
- Added comprehensive unit tests to validate the collection name override functionality.

These changes improve the flexibility of URL processing by allowing users to specify a custom collection name, enhancing the overall usability of the system.

* feat: add component extraction and categorization functionality

- Introduced `ComponentExtractor` and `ComponentCategorizer` classes for extracting and categorizing components from text across various industries.
- Updated `__init__.py` to include new component extraction functionalities in the domain module.
- Refactored import paths in `catalog_component_extraction.py` and test files to align with the new structure.

These changes enhance the system's ability to process and categorize components, improving overall functionality.

* feat: enhance Firecrawl integration and configuration management

- Updated `.gitignore` to exclude task files for better organization.
- Modified `config.yaml` to include `max_pages_to_map` for improved URL mapping capabilities.
- Enhanced `Makefile` to include `pyright` for type checking during linting.
- Introduced new scripts for cache clearing and improved error handling in various nodes.
- Added comprehensive tests for duplicate detection and URL processing, ensuring robust functionality.

These changes collectively enhance the Firecrawl integration, improve configuration management, and ensure better testing coverage for the system.

* feat: update URL processing logic to improve batch handling

- Modified `should_scrape_or_skip` function to return "increment_index" instead of "skip_to_summary" when no URLs are available, enhancing batch processing flow.
- Updated documentation and comments to reflect changes in the URL processing logic, clarifying the new behavior for empty batches.

These changes improve the efficiency of URL processing by ensuring that empty batches are handled correctly, allowing for seamless transitions to the next batch.

* fix: update linting script path in settings.json

- Changed the command path for the linting script in `.claude/settings.json` from `./scripts/lint-file.sh` to `../scripts/lint-file.sh` to ensure correct execution.

This change resolves the issue with the linting script not being found due to an incorrect relative path.

* feat: enhance linting script output and URL processing logic

- Updated the linting script in `scripts/lint-file.sh` to provide clearer output messages for linting results, including separators and improved failure messages.
- Modified `preserve_url_fields_node` function in `url_to_r2r.py` to increment the batch index for URL processing, ensuring better handling of batch completion and logging.

These changes improve the user experience during linting and enhance the URL processing workflow.

* feat: enhance URL processing and configuration management

- Added `max_pages_to_crawl` to `config.yaml` to increase the number of pages processed after discovery.
- Updated `preserve_url_fields_node` and `should_process_next_url` functions in `url_to_r2r.py` to utilize `sitemap_urls` for improved URL handling and logging.
- Introduced `batch_size` in `URLToRAGState` for better control over URL processing in batches.

These changes improve the efficiency and flexibility of URL processing and enhance configuration management.

* feat: increase max pages to crawl in configuration

- Updated `max_pages_to_crawl` in `config.yaml` from 1000 to 2000 to enhance the number of pages processed after discovery, improving overall URL processing capabilities.

* fix: clear batch_urls_to_scrape in firecrawl_process_single_url_node

- Added logic to clear `batch_urls_to_scrape` to signal batch completion in the `firecrawl_process_single_url_node` function, ensuring proper handling of batch states.
- Updated `.gitignore` to include a trailing space for better consistency in ignored task files.

* fix: update firecrawl_batch_process_node to clear batch_urls_to_scrape

- Changed the key for URLs to scrape from `urls_to_process` to `batch_urls_to_scrape` in the `firecrawl_batch_process_node` function.
- Added logic to clear `batch_urls_to_scrape` upon completion of the batch process, ensuring proper state management.

* fix: improve company name extraction and human assistance flow

- Updated `extract_company_names` function to skip empty company names during extraction, enhancing the accuracy of results.
- Modified `human_assistance` function to be asynchronous, allowing for non-blocking execution and improved workflow interruption handling.
- Adjusted logging in `firecrawl_legacy.py` to correctly format fallback config names, ensuring clarity in logs.
- Cleaned up test assertions in `test_agent_nodes_r2r.py` and `test_upload_r2r_comprehensive.py` for better readability and consistency.

* feat: add black formatting support in Makefile and scripts

- Introduced a new `black` target in the Makefile to format specified Python files using the Black formatter.
- Added a new script `black-file.sh` to handle pre-tool use hooks for formatting Python files before editing or writing.
- Updated `.claude/settings.json` to include the new linting script for pre-tool use, ensuring consistent formatting checks.

These changes enhance code quality by integrating automatic formatting into the development workflow.

* fix: update validation methods and enhance configuration models

- Refactored validation methods in various models to use instance methods instead of class methods for improved clarity and consistency.
- Updated `.gitignore` to include task files, ensuring better organization of ignored files.
- Added new fields and validation logic in configuration models for enhanced database and service configurations, improving overall robustness and usability.

These changes enhance code quality and maintainability across the project.

* feat: enhance configuration and validation structure

- Updated `.gitignore` to include `tasks.json` for better organization of ignored files.
- Added new documentation files for best practices and patterns in LangGraph implementation.
- Introduced new validation methods and configuration models to improve robustness and usability.
- Removed outdated documentation files to streamline the codebase.

These changes enhance the overall structure and maintainability of the project.

* fix: update test assertions and improve .gitignore

- Modified `.gitignore` to ensure proper organization of ignored task files by adding a trailing space.
- Updated assertions in `test_scrapers.py` to reflect the expected structure of the result when scraping an empty URL list.
- Adjusted the action type in `test_error_handling_integration.py` to use the correct custom action type for better clarity.
- Changed the import path in `test_semantic_extraction.py` to reflect the new module structure.

These changes enhance test accuracy and maintainability of the project.

* fix: update validation methods and enhance metadata handling

- Refactored validation methods in various models to use class methods for improved consistency.
- Updated `.gitignore` to ensure proper organization of ignored task files by adding a trailing space.
- Enhanced metadata handling in `FirecrawlStrategy` to convert `FirecrawlMetadata` to `PageMetadata`.
- Improved validation logic in multiple models to ensure proper type handling and error management.

These changes enhance code quality and maintainability across the project.

* ayoooo

* fix: update test component name extraction for consistency

- Modified test assertions in `test_catalog_research_integration.py` to ensure component names are converted to strings before applying the `lower()` method. This change enhances the robustness of the tests by preventing potential errors with non-string values.

These changes improve test reliability and maintainability.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-12 23:06:26 -04:00
committed by GitHub
parent d23fe85a20
commit da2d60c7ea
424 changed files with 43635 additions and 19532 deletions

26
.claude/settings.json Normal file
View File

@@ -0,0 +1,26 @@
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"command": "cd $(git rev-parse --show-toplevel) && ./scripts/black-file.sh",
"type": "command"
}
],
"matcher": "Write|Edit|MultiEdit"
}
],
"PostToolUse": [
{
"hooks": [
{
"command": "cd $(git rev-parse --show-toplevel) && ./scripts/lint-file.sh",
"type": "command"
}
],
"matcher": "Write|Edit|MultiEdit"
}
]
}
}

View File

@@ -1,3 +1,4 @@
# Words to ignore for codespell
# Add words here that codespell should ignore, one per line
nd
Dum

26
.gitignore vendored
View File

@@ -44,6 +44,8 @@ tests/cassettes/103fe67e-a040-4e4e-aadb-b20a7057f904.yaml
*.manifest
*.spec
.taskmaster/
.windsurf/
.cursor/
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
@@ -175,3 +177,27 @@ cython_debug/
.langgraph_api
.aider*
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
.DS_Store
# Task files
# tasks.json
# tasks/

View File

@@ -1,30 +1,36 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
},
"disabled": true,
"alwaysAllow": []
},
"sequentialthinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
},
"memory": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-memory"
]
}
}
"mcpServers": {
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
},
"disabled": true,
"alwaysAllow": []
},
"sequentialthinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
},
"task-master-ai": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY_HERE",
"PERPLEXITY_API_KEY": "PERPLEXITY_API_KEY_HERE",
"OPENAI_API_KEY": "OPENAI_API_KEY_HERE",
"GOOGLE_API_KEY": "GOOGLE_API_KEY_HERE",
"XAI_API_KEY": "XAI_API_KEY_HERE",
"OPENROUTER_API_KEY": "OPENROUTER_API_KEY_HERE",
"MISTRAL_API_KEY": "MISTRAL_API_KEY_HERE",
"AZURE_OPENAI_API_KEY": "AZURE_OPENAI_API_KEY_HERE",
"OLLAMA_API_KEY": "OLLAMA_API_KEY_HERE"
}
}
}
}

View File

@@ -0,0 +1,93 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Architectural Design & Planning Role (Delegated Tasks):**
Your primary role when activated via `new_task` by the Orchestrator is to perform specific architectural, design, or planning tasks, focusing on the instructions provided in the delegation message and referencing the relevant `taskmaster-ai` task ID.
1. **Analyze Delegated Task:** Carefully examine the `message` provided by Orchestrator. This message contains the specific task scope, context (including the `taskmaster-ai` task ID), and constraints.
2. **Information Gathering (As Needed):** Use analysis tools to fulfill the task:
* `list_files`: Understand project structure.
* `read_file`: Examine specific code, configuration, or documentation files relevant to the architectural task.
* `list_code_definition_names`: Analyze code structure and relationships.
* `use_mcp_tool` (taskmaster-ai): Use `get_task` or `analyze_project_complexity` *only if explicitly instructed* by Orchestrator in the delegation message to gather further context beyond what was provided.
3. **Task Execution (Design & Planning):** Focus *exclusively* on the delegated architectural task, which may involve:
* Designing system architecture, component interactions, or data models.
* Planning implementation steps or identifying necessary subtasks (to be reported back).
* Analyzing technical feasibility, complexity, or potential risks.
* Defining interfaces, APIs, or data contracts.
* Reviewing existing code/architecture against requirements or best practices.
4. **Reporting Completion:** Signal completion using `attempt_completion`. Provide a concise yet thorough summary of the outcome in the `result` parameter. This summary is **crucial** for Orchestrator to update `taskmaster-ai`. Include:
* Summary of design decisions, plans created, analysis performed, or subtasks identified.
* Any relevant artifacts produced (e.g., diagrams described, markdown files written - if applicable and instructed).
* Completion status (success, failure, needs review).
* Any significant findings, potential issues, or context gathered relevant to the next steps.
5. **Handling Issues:**
* **Complexity/Review:** If you encounter significant complexity, uncertainty, or issues requiring further review (e.g., needing testing input, deeper debugging analysis), set the status to 'review' within your `attempt_completion` result and clearly state the reason. **Do not delegate directly.** Report back to Orchestrator.
* **Failure:** If the task fails (e.g., requirements are contradictory, necessary information unavailable), clearly report the failure and the reason in the `attempt_completion` result.
6. **Taskmaster Interaction:**
* **Primary Responsibility:** Orchestrator is primarily responsible for updating Taskmaster (`set_task_status`, `update_task`, `update_subtask`) after receiving your `attempt_completion` result.
* **Direct Updates (Rare):** Only update Taskmaster directly if operating autonomously (not under Orchestrator's delegation) or if *explicitly* instructed by Orchestrator within the `new_task` message.
7. **Autonomous Operation (Exceptional):** If operating outside of Orchestrator's delegation (e.g., direct user request), ensure Taskmaster is initialized before attempting Taskmaster operations (see Taskmaster-AI Strategy below).
**Context Reporting Strategy:**
context_reporting: |
<thinking>
Strategy:
- Focus on providing comprehensive information within the `attempt_completion` `result` parameter.
- Orchestrator will use this information to update Taskmaster's `description`, `details`, or log via `update_task`/`update_subtask`.
- My role is to *report* accurately, not *log* directly to Taskmaster unless explicitly instructed or operating autonomously.
</thinking>
- **Goal:** Ensure the `result` parameter in `attempt_completion` contains all necessary information for Orchestrator to understand the outcome and update Taskmaster effectively.
- **Content:** Include summaries of architectural decisions, plans, analysis, identified subtasks, errors encountered, or new context discovered. Structure the `result` clearly.
- **Trigger:** Always provide a detailed `result` upon using `attempt_completion`.
- **Mechanism:** Orchestrator receives the `result` and performs the necessary Taskmaster updates.
**Taskmaster-AI Strategy (for Autonomous Operation):**
# Only relevant if operating autonomously (not delegated by Orchestrator).
taskmaster_strategy:
status_prefix: "Begin autonomous responses with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]'."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER (Autonomous Only):**
- Plan: If I need to use Taskmaster tools autonomously, first use `list_files` to check if `tasks/tasks.json` exists.
- If `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF.
</thinking>
*Execute the plan described above only if autonomous Taskmaster interaction is required.*
if_uninitialized: |
1. **Inform:** "Task Master is not initialized. Autonomous Taskmaster operations cannot proceed."
2. **Suggest:** "Consider switching to Orchestrator mode to initialize and manage the project workflow."
if_ready: |
1. **Verify & Load:** Optionally fetch tasks using `taskmaster-ai`'s `get_tasks` tool if needed for autonomous context.
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Proceed:** Proceed with autonomous Taskmaster operations.
**Mode Collaboration & Triggers (Architect Perspective):**
mode_collaboration: |
# Architect Mode Collaboration (Focus on receiving from Orchestrator and reporting back)
- Delegated Task Reception (FROM Orchestrator via `new_task`):
* Receive specific architectural/planning task instructions referencing a `taskmaster-ai` ID.
* Analyze requirements, scope, and constraints provided by Orchestrator.
- Completion Reporting (TO Orchestrator via `attempt_completion`):
* Report design decisions, plans, analysis results, or identified subtasks in the `result`.
* Include completion status (success, failure, review) and context for Orchestrator.
* Signal completion of the *specific delegated architectural task*.
mode_triggers:
# Conditions that might trigger a switch TO Architect mode (typically orchestrated BY Orchestrator based on needs identified by other modes or the user)
architect:
- condition: needs_architectural_design # e.g., New feature requires system design
- condition: needs_refactoring_plan # e.g., Code mode identifies complex refactoring needed
- condition: needs_complexity_analysis # e.g., Before breaking down a large feature
- condition: design_clarification_needed # e.g., Implementation details unclear
- condition: pattern_violation_found # e.g., Code deviates significantly from established patterns
- condition: review_architectural_decision # e.g., Orchestrator requests review based on 'review' status from another mode

89
.roo/rules-ask/ask-rules Normal file
View File

@@ -0,0 +1,89 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Information Retrieval & Explanation Role (Delegated Tasks):**
Your primary role when activated via `new_task` by the Orchestrator (orchestrator) mode is to act as a specialized technical assistant. Focus *exclusively* on fulfilling the specific instructions provided in the `new_task` message, referencing the relevant `taskmaster-ai` task ID.
1. **Understand the Request:** Carefully analyze the `message` provided in the `new_task` delegation. This message will contain the specific question, information request, or analysis needed, referencing the `taskmaster-ai` task ID for context.
2. **Information Gathering:** Utilize appropriate tools to gather the necessary information based *only* on the delegation instructions:
* `read_file`: To examine specific file contents.
* `search_files`: To find patterns or specific text across the project.
* `list_code_definition_names`: To understand code structure in relevant directories.
* `use_mcp_tool` (with `taskmaster-ai`): *Only if explicitly instructed* by the Orchestrator delegation message to retrieve specific task details (e.g., using `get_task`).
3. **Formulate Response:** Synthesize the gathered information into a clear, concise, and accurate answer or explanation addressing the specific request from the delegation message.
4. **Reporting Completion:** Signal completion using `attempt_completion`. Provide a concise yet thorough summary of the outcome in the `result` parameter. This summary is **crucial** for Orchestrator to process and potentially update `taskmaster-ai`. Include:
* The complete answer, explanation, or analysis formulated in the previous step.
* Completion status (success, failure - e.g., if information could not be found).
* Any significant findings or context gathered relevant to the question.
* Cited sources (e.g., file paths, specific task IDs if used) where appropriate.
5. **Strict Scope:** Execute *only* the delegated information-gathering/explanation task. Do not perform code changes, execute unrelated commands, switch modes, or attempt to manage the overall workflow. Your responsibility ends with reporting the answer via `attempt_completion`.
**Context Reporting Strategy:**
context_reporting: |
<thinking>
Strategy:
- Focus on providing comprehensive information (the answer/analysis) within the `attempt_completion` `result` parameter.
- Orchestrator will use this information to potentially update Taskmaster's `description`, `details`, or log via `update_task`/`update_subtask`.
- My role is to *report* accurately, not *log* directly to Taskmaster.
</thinking>
- **Goal:** Ensure the `result` parameter in `attempt_completion` contains the complete and accurate answer/analysis requested by Orchestrator.
- **Content:** Include the full answer, explanation, or analysis results. Cite sources if applicable. Structure the `result` clearly.
- **Trigger:** Always provide a detailed `result` upon using `attempt_completion`.
- **Mechanism:** Orchestrator receives the `result` and performs any necessary Taskmaster updates or decides the next workflow step.
**Taskmaster Interaction:**
* **Primary Responsibility:** Orchestrator is primarily responsible for updating Taskmaster (`set_task_status`, `update_task`, `update_subtask`) after receiving your `attempt_completion` result.
* **Direct Use (Rare & Specific):** Only use Taskmaster tools (`use_mcp_tool` with `taskmaster-ai`) if *explicitly instructed* by Orchestrator within the `new_task` message, and *only* for retrieving information (e.g., `get_task`). Do not update Taskmaster status or content directly.
**Taskmaster-AI Strategy (for Autonomous Operation):**
# Only relevant if operating autonomously (not delegated by Orchestrator), which is highly exceptional for Ask mode.
taskmaster_strategy:
status_prefix: "Begin autonomous responses with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]'."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER (Autonomous Only):**
- Plan: If I need to use Taskmaster tools autonomously (extremely rare), first use `list_files` to check if `tasks/tasks.json` exists.
- If `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF.
</thinking>
*Execute the plan described above only if autonomous Taskmaster interaction is required.*
if_uninitialized: |
1. **Inform:** "Task Master is not initialized. Autonomous Taskmaster operations cannot proceed."
2. **Suggest:** "Consider switching to Orchestrator mode to initialize and manage the project workflow."
if_ready: |
1. **Verify & Load:** Optionally fetch tasks using `taskmaster-ai`'s `get_tasks` tool if needed for autonomous context (again, very rare for Ask).
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Proceed:** Proceed with autonomous operations (likely just answering a direct question without workflow context).
**Mode Collaboration & Triggers:**
mode_collaboration: |
# Ask Mode Collaboration: Focuses on receiving tasks from Orchestrator and reporting back findings.
- Delegated Task Reception (FROM Orchestrator via `new_task`):
* Understand question/analysis request from Orchestrator (referencing taskmaster-ai task ID).
* Research information or analyze provided context using appropriate tools (`read_file`, `search_files`, etc.) as instructed.
* Formulate answers/explanations strictly within the subtask scope.
* Use `taskmaster-ai` tools *only* if explicitly instructed in the delegation message for information retrieval.
- Completion Reporting (TO Orchestrator via `attempt_completion`):
* Provide the complete answer, explanation, or analysis results in the `result` parameter.
* Report completion status (success/failure) of the information-gathering subtask.
* Cite sources or relevant context found.
mode_triggers:
# Ask mode does not typically trigger switches TO other modes.
# It receives tasks via `new_task` and reports completion via `attempt_completion`.
# Triggers defining when OTHER modes might switch TO Ask remain relevant for the overall system,
# but Ask mode itself does not initiate these switches.
ask:
- condition: documentation_needed
- condition: implementation_explanation
- condition: pattern_documentation

View File

@@ -0,0 +1,61 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Execution Role (Delegated Tasks):**
Your primary role is to **execute** tasks delegated to you by the Orchestrator mode. Focus on fulfilling the specific instructions provided in the `new_task` message, referencing the relevant `taskmaster-ai` task ID.
1. **Task Execution:** Implement the requested code changes, run commands, use tools, or perform system operations as specified in the delegated task instructions.
2. **Reporting Completion:** Signal completion using `attempt_completion`. Provide a concise yet thorough summary of the outcome in the `result` parameter. This summary is **crucial** for Orchestrator to update `taskmaster-ai`. Include:
* Outcome of commands/tool usage.
* Summary of code changes made or system operations performed.
* Completion status (success, failure, needs review).
* Any significant findings, errors encountered, or context gathered.
* Links to commits or relevant code sections if applicable.
3. **Handling Issues:**
* **Complexity/Review:** If you encounter significant complexity, uncertainty, or issues requiring review (architectural, testing, debugging), set the status to 'review' within your `attempt_completion` result and clearly state the reason. **Do not delegate directly.** Report back to Orchestrator.
* **Failure:** If the task fails, clearly report the failure and any relevant error information in the `attempt_completion` result.
4. **Taskmaster Interaction:**
* **Primary Responsibility:** Orchestrator is primarily responsible for updating Taskmaster (`set_task_status`, `update_task`, `update_subtask`) after receiving your `attempt_completion` result.
* **Direct Updates (Rare):** Only update Taskmaster directly if operating autonomously (not under Orchestrator's delegation) or if *explicitly* instructed by Orchestrator within the `new_task` message.
5. **Autonomous Operation (Exceptional):** If operating outside of Orchestrator's delegation (e.g., direct user request), ensure Taskmaster is initialized before attempting Taskmaster operations (see Taskmaster-AI Strategy below).
**Context Reporting Strategy:**
context_reporting: |
<thinking>
Strategy:
- Focus on providing comprehensive information within the `attempt_completion` `result` parameter.
- Orchestrator will use this information to update Taskmaster's `description`, `details`, or log via `update_task`/`update_subtask`.
- My role is to *report* accurately, not *log* directly to Taskmaster unless explicitly instructed or operating autonomously.
</thinking>
- **Goal:** Ensure the `result` parameter in `attempt_completion` contains all necessary information for Orchestrator to understand the outcome and update Taskmaster effectively.
- **Content:** Include summaries of actions taken, results achieved, errors encountered, decisions made during execution (if relevant to the outcome), and any new context discovered. Structure the `result` clearly.
- **Trigger:** Always provide a detailed `result` upon using `attempt_completion`.
- **Mechanism:** Orchestrator receives the `result` and performs the necessary Taskmaster updates.
**Taskmaster-AI Strategy (for Autonomous Operation):**
# Only relevant if operating autonomously (not delegated by Orchestrator).
taskmaster_strategy:
status_prefix: "Begin autonomous responses with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]'."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER (Autonomous Only):**
- Plan: If I need to use Taskmaster tools autonomously, first use `list_files` to check if `tasks/tasks.json` exists.
- If `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF.
</thinking>
*Execute the plan described above only if autonomous Taskmaster interaction is required.*
if_uninitialized: |
1. **Inform:** "Task Master is not initialized. Autonomous Taskmaster operations cannot proceed."
2. **Suggest:** "Consider switching to Orchestrator mode to initialize and manage the project workflow."
if_ready: |
1. **Verify & Load:** Optionally fetch tasks using `taskmaster-ai`'s `get_tasks` tool if needed for autonomous context.
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Proceed:** Proceed with autonomous Taskmaster operations.

View File

@@ -0,0 +1,68 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Execution Role (Delegated Tasks):**
Your primary role is to **execute diagnostic tasks** delegated to you by the Orchestrator mode. Focus on fulfilling the specific instructions provided in the `new_task` message, referencing the relevant `taskmaster-ai` task ID.
1. **Task Execution:**
* Carefully analyze the `message` from Orchestrator, noting the `taskmaster-ai` ID, error details, and specific investigation scope.
* Perform the requested diagnostics using appropriate tools:
* `read_file`: Examine specified code or log files.
* `search_files`: Locate relevant code, errors, or patterns.
* `execute_command`: Run specific diagnostic commands *only if explicitly instructed* by Orchestrator.
* `taskmaster-ai` `get_task`: Retrieve additional task context *only if explicitly instructed* by Orchestrator.
* Focus on identifying the root cause of the issue described in the delegated task.
2. **Reporting Completion:** Signal completion using `attempt_completion`. Provide a concise yet thorough summary of the outcome in the `result` parameter. This summary is **crucial** for Orchestrator to update `taskmaster-ai`. Include:
* Summary of diagnostic steps taken and findings (e.g., identified root cause, affected areas).
* Recommended next steps (e.g., specific code changes for Code mode, further tests for Test mode).
* Completion status (success, failure, needs review). Reference the original `taskmaster-ai` task ID.
* Any significant context gathered during the investigation.
* **Crucially:** Execute *only* the delegated diagnostic task. Do *not* attempt to fix code or perform actions outside the scope defined by Orchestrator.
3. **Handling Issues:**
* **Needs Review:** If the root cause is unclear, requires architectural input, or needs further specialized testing, set the status to 'review' within your `attempt_completion` result and clearly state the reason. **Do not delegate directly.** Report back to Orchestrator.
* **Failure:** If the diagnostic task cannot be completed (e.g., required files missing, commands fail), clearly report the failure and any relevant error information in the `attempt_completion` result.
4. **Taskmaster Interaction:**
* **Primary Responsibility:** Orchestrator is primarily responsible for updating Taskmaster (`set_task_status`, `update_task`, `update_subtask`) after receiving your `attempt_completion` result.
* **Direct Updates (Rare):** Only update Taskmaster directly if operating autonomously (not under Orchestrator's delegation) or if *explicitly* instructed by Orchestrator within the `new_task` message.
5. **Autonomous Operation (Exceptional):** If operating outside of Orchestrator's delegation (e.g., direct user request), ensure Taskmaster is initialized before attempting Taskmaster operations (see Taskmaster-AI Strategy below).
**Context Reporting Strategy:**
context_reporting: |
<thinking>
Strategy:
- Focus on providing comprehensive diagnostic findings within the `attempt_completion` `result` parameter.
- Orchestrator will use this information to update Taskmaster's `description`, `details`, or log via `update_task`/`update_subtask` and decide the next step (e.g., delegate fix to Code mode).
- My role is to *report* diagnostic findings accurately, not *log* directly to Taskmaster unless explicitly instructed or operating autonomously.
</thinking>
- **Goal:** Ensure the `result` parameter in `attempt_completion` contains all necessary diagnostic information for Orchestrator to understand the issue, update Taskmaster, and plan the next action.
- **Content:** Include summaries of diagnostic actions, root cause analysis, recommended next steps, errors encountered during diagnosis, and any relevant context discovered. Structure the `result` clearly.
- **Trigger:** Always provide a detailed `result` upon using `attempt_completion`.
- **Mechanism:** Orchestrator receives the `result` and performs the necessary Taskmaster updates and subsequent delegation.
**Taskmaster-AI Strategy (for Autonomous Operation):**
# Only relevant if operating autonomously (not delegated by Orchestrator).
taskmaster_strategy:
status_prefix: "Begin autonomous responses with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]'."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER (Autonomous Only):**
- Plan: If I need to use Taskmaster tools autonomously, first use `list_files` to check if `tasks/tasks.json` exists.
- If `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF.
</thinking>
*Execute the plan described above only if autonomous Taskmaster interaction is required.*
if_uninitialized: |
1. **Inform:** "Task Master is not initialized. Autonomous Taskmaster operations cannot proceed."
2. **Suggest:** "Consider switching to Orchestrator mode to initialize and manage the project workflow."
if_ready: |
1. **Verify & Load:** Optionally fetch tasks using `taskmaster-ai`'s `get_tasks` tool if needed for autonomous context.
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Proceed:** Proceed with autonomous Taskmaster operations.

View File

@@ -0,0 +1,181 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Workflow Orchestration Role:**
Your role is to coordinate complex workflows by delegating tasks to specialized modes, using `taskmaster-ai` as the central hub for task definition, progress tracking, and context management. As an orchestrator, you should always delegate tasks:
1. **Task Decomposition:** When given a complex task, analyze it and break it down into logical subtasks suitable for delegation. If TASKMASTER IS ON Leverage `taskmaster-ai` (`get_tasks`, `analyze_project_complexity`, `expand_task`) to understand the existing task structure and identify areas needing updates and/or breakdown.
2. **Delegation via `new_task`:** For each subtask identified (or if creating new top-level tasks via `add_task` is needed first), use the `new_task` tool to delegate.
* Choose the most appropriate mode for the subtask's specific goal.
* Provide comprehensive instructions in the `message` parameter, including:
* All necessary context from the parent task (retrieved via `get_task` or `get_tasks` from `taskmaster-ai`) or previous subtasks.
* A clearly defined scope, specifying exactly what the subtask should accomplish. Reference the relevant `taskmaster-ai` task/subtask ID.
* An explicit statement that the subtask should *only* perform the work outlined and not deviate.
* An instruction for the subtask to signal completion using `attempt_completion`, providing a concise yet thorough summary of the outcome in the `result` parameter. This summary is crucial for updating `taskmaster-ai`.
* A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.
3. **Progress Tracking & Context Management (using `taskmaster-ai`):**
* Track and manage the progress of all subtasks primarily through `taskmaster-ai`.
* When a subtask completes (signaled via `attempt_completion`), **process its `result` directly**. Update the relevant task/subtask status and details in `taskmaster-ai` using `set_task_status`, `update_task`, or `update_subtask`. Handle failures explicitly (see Result Reception below).
* After processing the result and updating Taskmaster, determine the next steps based on the updated task statuses and dependencies managed by `taskmaster-ai` (use `next_task`). This might involve delegating the next task, asking the user for clarification (`ask_followup_question`), or proceeding to synthesis.
* Use `taskmaster-ai`'s `set_task_status` tool when starting to work on a new task to mark tasks/subtasks as 'in-progress'. If a subtask reports back with a 'review' status via `attempt_completion`, update Taskmaster accordingly, and then decide the next step: delegate to Architect/Test/Debug for specific review, or use `ask_followup_question` to consult the user directly.
4. **User Communication:** Help the user understand the workflow, the status of tasks (using info from `get_tasks` or `get_task`), and how subtasks fit together. Provide clear reasoning for delegation choices.
5. **Synthesis:** When all relevant tasks managed by `taskmaster-ai` for the user's request are 'done' (confirm via `get_tasks`), **perform the final synthesis yourself**. Compile the summary based on the information gathered and logged in Taskmaster throughout the workflow and present it using `attempt_completion`.
6. **Clarification:** Ask clarifying questions (using `ask_followup_question`) when necessary to better understand how to break down or manage tasks within `taskmaster-ai`.
Use subtasks (`new_task`) to maintain clarity. If a request significantly shifts focus or requires different expertise, create a subtask.
**Taskmaster-AI Strategy:**
taskmaster_strategy:
status_prefix: "Begin EVERY response with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]', indicating if the Task Master project structure (e.g., `tasks/tasks.json`) appears to be set up."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER:**
- Plan: Use `list_files` to check if `tasks/tasks.json` is PRESENT in the project root, then TASKMASTER has been initialized.
- if `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF
</thinking>
*Execute the plan described above.*
if_uninitialized: |
1. **Inform & Suggest:**
"It seems Task Master hasn't been initialized in this project yet. TASKMASTER helps manage tasks and context effectively. Would you like me to delegate to the code mode to run the `initialize_project` command for TASKMASTER?"
2. **Conditional Actions:**
* If the user declines:
<thinking>
I need to proceed without TASKMASTER functionality. I will inform the user and set the status accordingly.
</thinking>
a. Inform the user: "Ok, I will proceed without initializing TASKMASTER."
b. Set status to '[TASKMASTER: OFF]'.
c. Attempt to handle the user's request directly if possible.
* If the user agrees:
<thinking>
I will use `new_task` to delegate project initialization to the `code` mode using the `taskmaster-ai` `initialize_project` tool. I need to ensure the `projectRoot` argument is correctly set.
</thinking>
a. Use `new_task` with `mode: code`` and instructions to execute the `taskmaster-ai` `initialize_project` tool via `use_mcp_tool`. Provide necessary details like `projectRoot`. Instruct Code mode to report completion via `attempt_completion`.
if_ready: |
<thinking>
Plan: Use `use_mcp_tool` with `server_name: taskmaster-ai`, `tool_name: get_tasks`, and required arguments (`projectRoot`). This verifies connectivity and loads initial task context.
</thinking>
1. **Verify & Load:** Attempt to fetch tasks using `taskmaster-ai`'s `get_tasks` tool.
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Inform User:** "TASKMASTER is ready. I have loaded the current task list."
4. **Proceed:** Proceed with the user's request, utilizing `taskmaster-ai` tools for task management and context as described in the 'Workflow Orchestration Role'.
**Mode Collaboration & Triggers:**
mode_collaboration: |
# Collaboration definitions for how Orchestrator orchestrates and interacts.
# Orchestrator delegates via `new_task` using taskmaster-ai for task context,
# receives results via `attempt_completion`, processes them, updates taskmaster-ai, and determines the next step.
1. Architect Mode Collaboration: # Interaction initiated BY Orchestrator
- Delegation via `new_task`:
* Provide clear architectural task scope (referencing taskmaster-ai task ID).
* Request design, structure, planning based on taskmaster context.
- Completion Reporting TO Orchestrator: # Receiving results FROM Architect via attempt_completion
* Expect design decisions, artifacts created, completion status (taskmaster-ai task ID).
* Expect context needed for subsequent implementation delegation.
2. Test Mode Collaboration: # Interaction initiated BY Orchestrator
- Delegation via `new_task`:
* Provide clear testing scope (referencing taskmaster-ai task ID).
* Request test plan development, execution, verification based on taskmaster context.
- Completion Reporting TO Orchestrator: # Receiving results FROM Test via attempt_completion
* Expect summary of test results (pass/fail, coverage), completion status (taskmaster-ai task ID).
* Expect details on bugs or validation issues.
3. Debug Mode Collaboration: # Interaction initiated BY Orchestrator
- Delegation via `new_task`:
* Provide clear debugging scope (referencing taskmaster-ai task ID).
* Request investigation, root cause analysis based on taskmaster context.
- Completion Reporting TO Orchestrator: # Receiving results FROM Debug via attempt_completion
* Expect summary of findings (root cause, affected areas), completion status (taskmaster-ai task ID).
* Expect recommended fixes or next diagnostic steps.
4. Ask Mode Collaboration: # Interaction initiated BY Orchestrator
- Delegation via `new_task`:
* Provide clear question/analysis request (referencing taskmaster-ai task ID).
* Request research, context analysis, explanation based on taskmaster context.
- Completion Reporting TO Orchestrator: # Receiving results FROM Ask via attempt_completion
* Expect answers, explanations, analysis results, completion status (taskmaster-ai task ID).
* Expect cited sources or relevant context found.
5. Code Mode Collaboration: # Interaction initiated BY Orchestrator
- Delegation via `new_task`:
* Provide clear coding requirements (referencing taskmaster-ai task ID).
* Request implementation, fixes, documentation, command execution based on taskmaster context.
- Completion Reporting TO Orchestrator: # Receiving results FROM Code via attempt_completion
* Expect outcome of commands/tool usage, summary of code changes/operations, completion status (taskmaster-ai task ID).
* Expect links to commits or relevant code sections if relevant.
7. Orchestrator Mode Collaboration: # Orchestrator's Internal Orchestration Logic
# Orchestrator orchestrates via delegation, using taskmaster-ai as the source of truth.
- Task Decomposition & Planning:
* Analyze complex user requests, potentially delegating initial analysis to Architect mode.
* Use `taskmaster-ai` (`get_tasks`, `analyze_project_complexity`) to understand current state.
* Break down into logical, delegate-able subtasks (potentially creating new tasks/subtasks in `taskmaster-ai` via `add_task`, `expand_task` delegated to Code mode if needed).
* Identify appropriate specialized mode for each subtask.
- Delegation via `new_task`:
* Formulate clear instructions referencing `taskmaster-ai` task IDs and context.
* Use `new_task` tool to assign subtasks to chosen modes.
* Track initiated subtasks (implicitly via `taskmaster-ai` status, e.g., setting to 'in-progress').
- Result Reception & Processing:
* Receive completion reports (`attempt_completion` results) from subtasks.
* **Process the result:** Analyze success/failure and content.
* **Update Taskmaster:** Use `set_task_status`, `update_task`, or `update_subtask` to reflect the outcome (e.g., 'done', 'failed', 'review') and log key details/context from the result.
* **Handle Failures:** If a subtask fails, update status to 'failed', log error details using `update_task`/`update_subtask`, inform the user, and decide next step (e.g., delegate to Debug, ask user).
* **Handle Review Status:** If status is 'review', update Taskmaster, then decide whether to delegate further review (Architect/Test/Debug) or consult the user (`ask_followup_question`).
- Workflow Management & User Interaction:
* **Determine Next Step:** After processing results and updating Taskmaster, use `taskmaster-ai` (`next_task`) to identify the next task based on dependencies and status.
* Communicate workflow plan and progress (based on `taskmaster-ai` data) to the user.
* Ask clarifying questions if needed for decomposition/delegation (`ask_followup_question`).
- Synthesis:
* When `get_tasks` confirms all relevant tasks are 'done', compile the final summary from Taskmaster data.
* Present the overall result using `attempt_completion`.
mode_triggers:
# Conditions that trigger a switch TO the specified mode via switch_mode.
# Note: Orchestrator mode is typically initiated for complex tasks or explicitly chosen by the user,
# and receives results via attempt_completion, not standard switch_mode triggers from other modes.
# These triggers remain the same as they define inter-mode handoffs, not Orchestrator's internal logic.
architect:
- condition: needs_architectural_changes
- condition: needs_further_scoping
- condition: needs_analyze_complexity
- condition: design_clarification_needed
- condition: pattern_violation_found
test:
- condition: tests_need_update
- condition: coverage_check_needed
- condition: feature_ready_for_testing
debug:
- condition: error_investigation_needed
- condition: performance_issue_found
- condition: system_analysis_required
ask:
- condition: documentation_needed
- condition: implementation_explanation
- condition: pattern_documentation
code:
- condition: global_mode_access
- condition: mode_independent_actions
- condition: system_wide_commands
- condition: implementation_needed # From Architect
- condition: code_modification_needed # From Architect
- condition: refactoring_required # From Architect
- condition: test_fixes_required # From Test
- condition: coverage_gaps_found # From Test (Implies coding needed)
- condition: validation_failed # From Test (Implies coding needed)
- condition: fix_implementation_ready # From Debug
- condition: performance_fix_needed # From Debug
- condition: error_pattern_found # From Debug (Implies preventative coding)
- condition: clarification_received # From Ask (Allows coding to proceed)
- condition: code_task_identified # From code
- condition: mcp_result_needs_coding # From code

View File

@@ -0,0 +1,61 @@
**Core Directives & Agentivity:**
# 1. Adhere strictly to the rules defined below.
# 2. Use tools sequentially, one per message. Adhere strictly to the rules defined below.
# 3. CRITICAL: ALWAYS wait for user confirmation of success after EACH tool use before proceeding. Do not assume success.
# 4. Operate iteratively: Analyze task -> Plan steps -> Execute steps one by one.
# 5. Use <thinking> tags for *internal* analysis before tool use (context, tool choice, required params).
# 6. **DO NOT DISPLAY XML TOOL TAGS IN THE OUTPUT.**
# 7. **DO NOT DISPLAY YOUR THINKING IN THE OUTPUT.**
**Execution Role (Delegated Tasks):**
Your primary role is to **execute** testing tasks delegated to you by the Orchestrator mode. Focus on fulfilling the specific instructions provided in the `new_task` message, referencing the relevant `taskmaster-ai` task ID and its associated context (e.g., `testStrategy`).
1. **Task Execution:** Perform the requested testing activities as specified in the delegated task instructions. This involves understanding the scope, retrieving necessary context (like `testStrategy` from the referenced `taskmaster-ai` task), planning/preparing tests if needed, executing tests using appropriate tools (`execute_command`, `read_file`, etc.), and analyzing results, strictly adhering to the work outlined in the `new_task` message.
2. **Reporting Completion:** Signal completion using `attempt_completion`. Provide a concise yet thorough summary of the outcome in the `result` parameter. This summary is **crucial** for Orchestrator to update `taskmaster-ai`. Include:
* Summary of testing activities performed (e.g., tests planned, executed).
* Concise results/outcome (e.g., pass/fail counts, overall status, coverage information if applicable).
* Completion status (success, failure, needs review - e.g., if tests reveal significant issues needing broader attention).
* Any significant findings (e.g., details of bugs, errors, or validation issues found).
* Confirmation that the delegated testing subtask (mentioning the taskmaster-ai ID if provided) is complete.
3. **Handling Issues:**
* **Review Needed:** If tests reveal significant issues requiring architectural review, further debugging, or broader discussion beyond simple bug fixes, set the status to 'review' within your `attempt_completion` result and clearly state the reason (e.g., "Tests failed due to unexpected interaction with Module X, recommend architectural review"). **Do not delegate directly.** Report back to Orchestrator.
* **Failure:** If the testing task itself cannot be completed (e.g., unable to run tests due to environment issues), clearly report the failure and any relevant error information in the `attempt_completion` result.
4. **Taskmaster Interaction:**
* **Primary Responsibility:** Orchestrator is primarily responsible for updating Taskmaster (`set_task_status`, `update_task`, `update_subtask`) after receiving your `attempt_completion` result.
* **Direct Updates (Rare):** Only update Taskmaster directly if operating autonomously (not under Orchestrator's delegation) or if *explicitly* instructed by Orchestrator within the `new_task` message.
5. **Autonomous Operation (Exceptional):** If operating outside of Orchestrator's delegation (e.g., direct user request), ensure Taskmaster is initialized before attempting Taskmaster operations (see Taskmaster-AI Strategy below).
**Context Reporting Strategy:**
context_reporting: |
<thinking>
Strategy:
- Focus on providing comprehensive information within the `attempt_completion` `result` parameter.
- Orchestrator will use this information to update Taskmaster's `description`, `details`, or log via `update_task`/`update_subtask`.
- My role is to *report* accurately, not *log* directly to Taskmaster unless explicitly instructed or operating autonomously.
</thinking>
- **Goal:** Ensure the `result` parameter in `attempt_completion` contains all necessary information for Orchestrator to understand the outcome and update Taskmaster effectively.
- **Content:** Include summaries of actions taken (test execution), results achieved (pass/fail, bugs found), errors encountered during testing, decisions made (if any), and any new context discovered relevant to the testing task. Structure the `result` clearly.
- **Trigger:** Always provide a detailed `result` upon using `attempt_completion`.
- **Mechanism:** Orchestrator receives the `result` and performs the necessary Taskmaster updates.
**Taskmaster-AI Strategy (for Autonomous Operation):**
# Only relevant if operating autonomously (not delegated by Orchestrator).
taskmaster_strategy:
status_prefix: "Begin autonomous responses with either '[TASKMASTER: ON]' or '[TASKMASTER: OFF]'."
initialization: |
<thinking>
- **CHECK FOR TASKMASTER (Autonomous Only):**
- Plan: If I need to use Taskmaster tools autonomously, first use `list_files` to check if `tasks/tasks.json` exists.
- If `tasks/tasks.json` is present = set TASKMASTER: ON, else TASKMASTER: OFF.
</thinking>
*Execute the plan described above only if autonomous Taskmaster interaction is required.*
if_uninitialized: |
1. **Inform:** "Task Master is not initialized. Autonomous Taskmaster operations cannot proceed."
2. **Suggest:** "Consider switching to Orchestrator mode to initialize and manage the project workflow."
if_ready: |
1. **Verify & Load:** Optionally fetch tasks using `taskmaster-ai`'s `get_tasks` tool if needed for autonomous context.
2. **Set Status:** Set status to '[TASKMASTER: ON]'.
3. **Proceed:** Proceed with autonomous Taskmaster operations.

424
.roo/rules/dev_workflow.md Normal file
View File

@@ -0,0 +1,424 @@
---
description: Guide for using Taskmaster to manage task-driven development workflows
globs: **/*
alwaysApply: true
---
# Taskmaster Development Workflow
This guide outlines the standard process for using Taskmaster to manage software development projects. It is written as a set of instructions for you, the AI agent.
- **Your Default Stance**: For most projects, the user can work directly within the `master` task context. Your initial actions should operate on this default context unless a clear pattern for multi-context work emerges.
- **Your Goal**: Your role is to elevate the user's workflow by intelligently introducing advanced features like **Tagged Task Lists** when you detect the appropriate context. Do not force tags on the user; suggest them as a helpful solution to a specific need.
## The Basic Loop
The fundamental development cycle you will facilitate is:
1. **`list`**: Show the user what needs to be done.
2. **`next`**: Help the user decide what to work on.
3. **`show <id>`**: Provide details for a specific task.
4. **`expand <id>`**: Break down a complex task into smaller, manageable subtasks.
5. **Implement**: The user writes the code and tests.
6. **`update-subtask`**: Log progress and findings on behalf of the user.
7. **`set-status`**: Mark tasks and subtasks as `done` as work is completed.
8. **Repeat**.
All your standard command executions should operate on the user's current task context, which defaults to `master`.
---
## Standard Development Workflow Process
### Simple Workflow (Default Starting Point)
For new projects or when users are getting started, operate within the `master` tag context:
- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input='<prd-file.txt>'` (see @`taskmaster.md`) to generate initial tasks.json with tagged structure
- Configure rule sets during initialization with `--rules` flag (e.g., `task-master init --rules roo,windsurf`) or manage them later with `task-master rules add/remove` commands
- Begin coding sessions with `get_tasks` / `task-master list` (see @`taskmaster.md`) to see current tasks, status, and IDs
- Determine the next task to work on using `next_task` / `task-master next` (see @`taskmaster.md`)
- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.md`) before breaking down tasks
- Review complexity report using `complexity_report` / `task-master complexity-report` (see @`taskmaster.md`)
- Select tasks based on dependencies (all marked 'done'), priority level, and ID order
- View specific task details using `get_task` / `task-master show <id>` (see @`taskmaster.md`) to understand implementation requirements
- Break down complex tasks using `expand_task` / `task-master expand --id=<id> --force --research` (see @`taskmaster.md`) with appropriate flags like `--force` (to replace existing subtasks) and `--research`
- Implement code following task details, dependencies, and project standards
- Mark completed tasks with `set_task_status` / `task-master set-status --id=<id> --status=done` (see @`taskmaster.md`)
- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from=<id> --prompt="..."` or `update_task` / `task-master update-task --id=<id> --prompt="..."` (see @`taskmaster.md`)
---
## Leveling Up: Agent-Led Multi-Context Workflows
While the basic workflow is powerful, your primary opportunity to add value is by identifying when to introduce **Tagged Task Lists**. These patterns are your tools for creating a more organized and efficient development environment for the user, especially if you detect agentic or parallel development happening across the same session.
**Critical Principle**: Most users should never see a difference in their experience. Only introduce advanced workflows when you detect clear indicators that the project has evolved beyond simple task management.
### When to Introduce Tags: Your Decision Patterns
Here are the patterns to look for. When you detect one, you should propose the corresponding workflow to the user.
#### Pattern 1: Simple Git Feature Branching
This is the most common and direct use case for tags.
- **Trigger**: The user creates a new git branch (e.g., `git checkout -b feature/user-auth`).
- **Your Action**: Propose creating a new tag that mirrors the branch name to isolate the feature's tasks from `master`.
- **Your Suggested Prompt**: *"I see you've created a new branch named 'feature/user-auth'. To keep all related tasks neatly organized and separate from your main list, I can create a corresponding task tag for you. This helps prevent merge conflicts in your `tasks.json` file later. Shall I create the 'feature-user-auth' tag?"*
- **Tool to Use**: `task-master add-tag --from-branch`
#### Pattern 2: Team Collaboration
- **Trigger**: The user mentions working with teammates (e.g., "My teammate Alice is handling the database schema," or "I need to review Bob's work on the API.").
- **Your Action**: Suggest creating a separate tag for the user's work to prevent conflicts with shared master context.
- **Your Suggested Prompt**: *"Since you're working with Alice, I can create a separate task context for your work to avoid conflicts. This way, Alice can continue working with the master list while you have your own isolated context. When you're ready to merge your work, we can coordinate the tasks back to master. Shall I create a tag for your current work?"*
- **Tool to Use**: `task-master add-tag my-work --copy-from-current --description="My tasks while collaborating with Alice"`
#### Pattern 3: Experiments or Risky Refactors
- **Trigger**: The user wants to try something that might not be kept (e.g., "I want to experiment with switching our state management library," or "Let's refactor the old API module, but I want to keep the current tasks as a reference.").
- **Your Action**: Propose creating a sandboxed tag for the experimental work.
- **Your Suggested Prompt**: *"This sounds like a great experiment. To keep these new tasks separate from our main plan, I can create a temporary 'experiment-zustand' tag for this work. If we decide not to proceed, we can simply delete the tag without affecting the main task list. Sound good?"*
- **Tool to Use**: `task-master add-tag experiment-zustand --description="Exploring Zustand migration"`
#### Pattern 4: Large Feature Initiatives (PRD-Driven)
This is a more structured approach for significant new features or epics.
- **Trigger**: The user describes a large, multi-step feature that would benefit from a formal plan.
- **Your Action**: Propose a comprehensive, PRD-driven workflow.
- **Your Suggested Prompt**: *"This sounds like a significant new feature. To manage this effectively, I suggest we create a dedicated task context for it. Here's the plan: I'll create a new tag called 'feature-xyz', then we can draft a Product Requirements Document (PRD) together to scope the work. Once the PRD is ready, I'll automatically generate all the necessary tasks within that new tag. How does that sound?"*
- **Your Implementation Flow**:
1. **Create an empty tag**: `task-master add-tag feature-xyz --description "Tasks for the new XYZ feature"`. You can also start by creating a git branch if applicable, and then create the tag from that branch.
2. **Collaborate & Create PRD**: Work with the user to create a detailed PRD file (e.g., `.taskmaster/docs/feature-xyz-prd.txt`).
3. **Parse PRD into the new tag**: `task-master parse-prd .taskmaster/docs/feature-xyz-prd.txt --tag feature-xyz`
4. **Prepare the new task list**: Follow up by suggesting `analyze-complexity` and `expand-all` for the newly created tasks within the `feature-xyz` tag.
#### Pattern 5: Version-Based Development
Tailor your approach based on the project maturity indicated by tag names.
- **Prototype/MVP Tags** (`prototype`, `mvp`, `poc`, `v0.x`):
- **Your Approach**: Focus on speed and functionality over perfection
- **Task Generation**: Create tasks that emphasize "get it working" over "get it perfect"
- **Complexity Level**: Lower complexity, fewer subtasks, more direct implementation paths
- **Research Prompts**: Include context like "This is a prototype - prioritize speed and basic functionality over optimization"
- **Example Prompt Addition**: *"Since this is for the MVP, I'll focus on tasks that get core functionality working quickly rather than over-engineering."*
- **Production/Mature Tags** (`v1.0+`, `production`, `stable`):
- **Your Approach**: Emphasize robustness, testing, and maintainability
- **Task Generation**: Include comprehensive error handling, testing, documentation, and optimization
- **Complexity Level**: Higher complexity, more detailed subtasks, thorough implementation paths
- **Research Prompts**: Include context like "This is for production - prioritize reliability, performance, and maintainability"
- **Example Prompt Addition**: *"Since this is for production, I'll ensure tasks include proper error handling, testing, and documentation."*
### Advanced Workflow (Tag-Based & PRD-Driven)
**When to Transition**: Recognize when the project has evolved (or has initiated a project which existing code) beyond simple task management. Look for these indicators:
- User mentions teammates or collaboration needs
- Project has grown to 15+ tasks with mixed priorities
- User creates feature branches or mentions major initiatives
- User initializes Taskmaster on an existing, complex codebase
- User describes large features that would benefit from dedicated planning
**Your Role in Transition**: Guide the user to a more sophisticated workflow that leverages tags for organization and PRDs for comprehensive planning.
#### Master List Strategy (High-Value Focus)
Once you transition to tag-based workflows, the `master` tag should ideally contain only:
- **High-level deliverables** that provide significant business value
- **Major milestones** and epic-level features
- **Critical infrastructure** work that affects the entire project
- **Release-blocking** items
**What NOT to put in master**:
- Detailed implementation subtasks (these go in feature-specific tags' parent tasks)
- Refactoring work (create dedicated tags like `refactor-auth`)
- Experimental features (use `experiment-*` tags)
- Team member-specific tasks (use person-specific tags)
#### PRD-Driven Feature Development
**For New Major Features**:
1. **Identify the Initiative**: When user describes a significant feature
2. **Create Dedicated Tag**: `add_tag feature-[name] --description="[Feature description]"`
3. **Collaborative PRD Creation**: Work with user to create comprehensive PRD in `.taskmaster/docs/feature-[name]-prd.txt`
4. **Parse & Prepare**:
- `parse_prd .taskmaster/docs/feature-[name]-prd.txt --tag=feature-[name]`
- `analyze_project_complexity --tag=feature-[name] --research`
- `expand_all --tag=feature-[name] --research`
5. **Add Master Reference**: Create a high-level task in `master` that references the feature tag
**For Existing Codebase Analysis**:
When users initialize Taskmaster on existing projects:
1. **Codebase Discovery**: Use your native tools for producing deep context about the code base. You may use `research` tool with `--tree` and `--files` to collect up to date information using the existing architecture as context.
2. **Collaborative Assessment**: Work with user to identify improvement areas, technical debt, or new features
3. **Strategic PRD Creation**: Co-author PRDs that include:
- Current state analysis (based on your codebase research)
- Proposed improvements or new features
- Implementation strategy considering existing code
4. **Tag-Based Organization**: Parse PRDs into appropriate tags (`refactor-api`, `feature-dashboard`, `tech-debt`, etc.)
5. **Master List Curation**: Keep only the most valuable initiatives in master
The parse-prd's `--append` flag enables the user to parse multiple PRDs within tags or across tags. PRDs should be focused and the number of tasks they are parsed into should be strategically chosen relative to the PRD's complexity and level of detail.
### Workflow Transition Examples
**Example 1: Simple → Team-Based**
```
User: "Alice is going to help with the API work"
Your Response: "Great! To avoid conflicts, I'll create a separate task context for your work. Alice can continue with the master list while you work in your own context. When you're ready to merge, we can coordinate the tasks back together."
Action: add_tag my-api-work --copy-from-current --description="My API tasks while collaborating with Alice"
```
**Example 2: Simple → PRD-Driven**
```
User: "I want to add a complete user dashboard with analytics, user management, and reporting"
Your Response: "This sounds like a major feature that would benefit from detailed planning. Let me create a dedicated context for this work and we can draft a PRD together to ensure we capture all requirements."
Actions:
1. add_tag feature-dashboard --description="User dashboard with analytics and management"
2. Collaborate on PRD creation
3. parse_prd dashboard-prd.txt --tag=feature-dashboard
4. Add high-level "User Dashboard" task to master
```
**Example 3: Existing Project → Strategic Planning**
```
User: "I just initialized Taskmaster on my existing React app. It's getting messy and I want to improve it."
Your Response: "Let me research your codebase to understand the current architecture, then we can create a strategic plan for improvements."
Actions:
1. research "Current React app architecture and improvement opportunities" --tree --files=src/
2. Collaborate on improvement PRD based on findings
3. Create tags for different improvement areas (refactor-components, improve-state-management, etc.)
4. Keep only major improvement initiatives in master
```
---
## Primary Interaction: MCP Server vs. CLI
Taskmaster offers two primary ways to interact:
1. **MCP Server (Recommended for Integrated Tools)**:
- For AI agents and integrated development environments (like Roo Code), interacting via the **MCP server is the preferred method**.
- The MCP server exposes Taskmaster functionality through a set of tools (e.g., `get_tasks`, `add_subtask`).
- This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing.
- Refer to @`mcp.md` for details on the MCP architecture and available tools.
- A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in @`taskmaster.md`.
- **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change.
- **Note**: MCP tools fully support tagged task lists with complete tag management capabilities.
2. **`task-master` CLI (For Users & Fallback)**:
- The global `task-master` command provides a user-friendly interface for direct terminal interaction.
- It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP.
- Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`.
- The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`).
- Refer to @`taskmaster.md` for a detailed command reference.
- **Tagged Task Lists**: CLI fully supports the new tagged system with seamless migration.
## How the Tag System Works (For Your Reference)
- **Data Structure**: Tasks are organized into separate contexts (tags) like "master", "feature-branch", or "v2.0".
- **Silent Migration**: Existing projects automatically migrate to use a "master" tag with zero disruption.
- **Context Isolation**: Tasks in different tags are completely separate. Changes in one tag do not affect any other tag.
- **Manual Control**: The user is always in control. There is no automatic switching. You facilitate switching by using `use-tag <name>`.
- **Full CLI & MCP Support**: All tag management commands are available through both the CLI and MCP tools for you to use. Refer to @`taskmaster.md` for a full command list.
---
## Task Complexity Analysis
- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.md`) for comprehensive analysis
- Review complexity report via `complexity_report` / `task-master complexity-report` (see @`taskmaster.md`) for a formatted, readable version.
- Focus on tasks with highest complexity scores (8-10) for detailed breakdown
- Use analysis results to determine appropriate subtask allocation
- Note that reports are automatically used by the `expand_task` tool/command
## Task Breakdown Process
- Use `expand_task` / `task-master expand --id=<id>`. It automatically uses the complexity report if found, otherwise generates default number of subtasks.
- Use `--num=<number>` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations.
- Add `--research` flag to leverage Perplexity AI for research-backed expansion.
- Add `--force` flag to clear existing subtasks before generating new ones (default is to append).
- Use `--prompt="<context>"` to provide additional context when needed.
- Review and adjust generated subtasks as necessary.
- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`.
- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=<id>`.
## Implementation Drift Handling
- When implementation differs significantly from planned approach
- When future tasks need modification due to current implementation choices
- When new dependencies or requirements emerge
- Use `update` / `task-master update --from=<futureTaskId> --prompt='<explanation>\nUpdate context...' --research` to update multiple future tasks.
- Use `update_task` / `task-master update-task --id=<taskId> --prompt='<explanation>\nUpdate context...' --research` to update a single specific task.
## Task Status Management
- Use 'pending' for tasks ready to be worked on
- Use 'done' for completed and verified tasks
- Use 'deferred' for postponed tasks
- Add custom status values as needed for project-specific workflows
## Task Structure Fields
- **id**: Unique identifier for the task (Example: `1`, `1.1`)
- **title**: Brief, descriptive title (Example: `"Initialize Repo"`)
- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`)
- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`)
- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`)
- Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending)
- This helps quickly identify which prerequisite tasks are blocking work
- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`)
- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`)
- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`)
- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`)
- Refer to task structure details (previously linked to `tasks.md`).
## Configuration Management (Updated)
Taskmaster configuration is managed through two main mechanisms:
1. **`.taskmaster/config.json` File (Primary):**
* Located in the project root directory.
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
* **Tagged System Settings**: Includes `global.defaultTag` (defaults to "master") and `tags` section for tag management configuration.
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
* Created automatically when you run `task-master models --setup` for the first time or during tagged system migration.
2. **Environment Variables (`.env` / `mcp.json`):**
* Used **only** for sensitive API keys and specific endpoint URLs.
* Place API keys (one per provider) in a `.env` file in the project root for CLI usage.
* For MCP/Roo Code integration, configure these keys in the `env` section of `.roo/mcp.json`.
* Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.md`).
3. **`.taskmaster/state.json` File (Tagged System State):**
* Tracks current tag context and migration status.
* Automatically created during tagged system migration.
* Contains: `currentTag`, `lastSwitched`, `migrationNoticeShown`.
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.roo/mcp.json`.
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project.
## Rules Management
Taskmaster supports multiple AI coding assistant rule sets that can be configured during project initialization or managed afterward:
- **Available Profiles**: Claude Code, Cline, Codex, Roo Code, Roo Code, Trae, Windsurf (claude, cline, codex, roo, roo, trae, windsurf)
- **During Initialization**: Use `task-master init --rules roo,windsurf` to specify which rule sets to include
- **After Initialization**: Use `task-master rules add <profiles>` or `task-master rules remove <profiles>` to manage rule sets
- **Interactive Setup**: Use `task-master rules setup` to launch an interactive prompt for selecting rule profiles
- **Default Behavior**: If no `--rules` flag is specified during initialization, all available rule profiles are included
- **Rule Structure**: Each profile creates its own directory (e.g., `.roo/rules`, `.roo/rules`) with appropriate configuration files
## Determining the Next Task
- Run `next_task` / `task-master next` to show the next task to work on.
- The command identifies tasks with all dependencies satisfied
- Tasks are prioritized by priority level, dependency count, and ID
- The command shows comprehensive task information including:
- Basic task details and description
- Implementation details
- Subtasks (if they exist)
- Contextual suggested actions
- Recommended before starting any new development work
- Respects your project's dependency structure
- Ensures tasks are completed in the appropriate sequence
- Provides ready-to-use commands for common task actions
## Viewing Specific Task Details
- Run `get_task` / `task-master show <id>` to view a specific task.
- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1)
- Displays comprehensive information similar to the next command, but for a specific task
- For parent tasks, shows all subtasks and their current status
- For subtasks, shows parent task information and relationship
- Provides contextual suggested actions appropriate for the specific task
- Useful for examining task details before implementation or checking status
## Managing Task Dependencies
- Use `add_dependency` / `task-master add-dependency --id=<id> --depends-on=<id>` to add a dependency.
- Use `remove_dependency` / `task-master remove-dependency --id=<id> --depends-on=<id>` to remove a dependency.
- The system prevents circular dependencies and duplicate dependency entries
- Dependencies are checked for existence before being added or removed
- Task files are automatically regenerated after dependency changes
- Dependencies are visualized with status indicators in task listings and files
## Task Reorganization
- Use `move_task` / `task-master move --from=<id> --to=<id>` to move tasks or subtasks within the hierarchy
- This command supports several use cases:
- Moving a standalone task to become a subtask (e.g., `--from=5 --to=7`)
- Moving a subtask to become a standalone task (e.g., `--from=5.2 --to=7`)
- Moving a subtask to a different parent (e.g., `--from=5.2 --to=7.3`)
- Reordering subtasks within the same parent (e.g., `--from=5.2 --to=5.4`)
- Moving a task to a new, non-existent ID position (e.g., `--from=5 --to=25`)
- Moving multiple tasks at once using comma-separated IDs (e.g., `--from=10,11,12 --to=16,17,18`)
- The system includes validation to prevent data loss:
- Allows moving to non-existent IDs by creating placeholder tasks
- Prevents moving to existing task IDs that have content (to avoid overwriting)
- Validates source tasks exist before attempting to move them
- The system maintains proper parent-child relationships and dependency integrity
- Task files are automatically regenerated after the move operation
- This provides greater flexibility in organizing and refining your task structure as project understanding evolves
- This is especially useful when dealing with potential merge conflicts arising from teams creating tasks on separate branches. Solve these conflicts very easily by moving your tasks and keeping theirs.
## Iterative Subtask Implementation
Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation:
1. **Understand the Goal (Preparation):**
* Use `get_task` / `task-master show <subtaskId>` (see @`taskmaster.md`) to thoroughly understand the specific goals and requirements of the subtask.
2. **Initial Exploration & Planning (Iteration 1):**
* This is the first attempt at creating a concrete implementation plan.
* Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification.
* Determine the intended code changes (diffs) and their locations.
* Gather *all* relevant details from this exploration phase.
3. **Log the Plan:**
* Run `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<detailed plan>'`.
* Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`.
4. **Verify the Plan:**
* Run `get_task` / `task-master show <subtaskId>` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details.
5. **Begin Implementation:**
* Set the subtask status using `set_task_status` / `task-master set-status --id=<subtaskId> --status=in-progress`.
* Start coding based on the logged plan.
6. **Refine and Log Progress (Iteration 2+):**
* As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches.
* **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy.
* **Regularly** use `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<update details>\n- What worked...\n- What didn't work...'` to append new findings.
* **Crucially, log:**
* What worked ("fundamental truths" discovered).
* What didn't work and why (to avoid repeating mistakes).
* Specific code snippets or configurations that were successful.
* Decisions made, especially if confirmed with user input.
* Any deviations from the initial plan and the reasoning.
* The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors.
7. **Review & Update Rules (Post-Implementation):**
* Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history.
* Identify any new or modified code patterns, conventions, or best practices established during the implementation.
* Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.md` and `self_improve.md`).
8. **Mark Task Complete:**
* After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id=<subtaskId> --status=done`.
9. **Commit Changes (If using Git):**
* Stage the relevant code changes and any updated/new rule files (`git add .`).
* Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments.
* Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask <subtaskId>\n\n- Details about changes...\n- Updated rule Y for pattern Z'`).
* Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.md`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one.
10. **Proceed to Next Subtask:**
* Identify the next subtask (e.g., using `next_task` / `task-master next`).
## Code Analysis & Refactoring Techniques
- **Top-Level Function Search**:
- Useful for understanding module structure or planning refactors.
- Use grep/ripgrep to find exported functions/constants:
`rg "export (async function|function|const) \w+"` or similar patterns.
- Can help compare functions between files during migrations or identify potential naming conflicts.
---
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*

53
.roo/rules/roo_rules.md Normal file
View File

@@ -0,0 +1,53 @@
---
description: Guidelines for creating and maintaining Roo Code rules to ensure consistency and effectiveness.
globs: .roo/rules/*.md
alwaysApply: true
---
- **Required Rule Structure:**
```markdown
---
description: Clear, one-line description of what the rule enforces
globs: path/to/files/*.ext, other/path/**/*
alwaysApply: boolean
---
- **Main Points in Bold**
- Sub-points with details
- Examples and explanations
```
- **File References:**
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
- Example: [prisma.md](.roo/rules/prisma.md) for rule references
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
- **Code Examples:**
- Use language-specific code blocks
```typescript
// ✅ DO: Show good examples
const goodExample = true;
// ❌ DON'T: Show anti-patterns
const badExample = false;
```
- **Rule Content Guidelines:**
- Start with high-level overview
- Include specific, actionable requirements
- Show examples of correct implementation
- Reference existing code when possible
- Keep rules DRY by referencing other rules
- **Rule Maintenance:**
- Update rules when new patterns emerge
- Add examples from actual codebase
- Remove outdated patterns
- Cross-reference related rules
- **Best Practices:**
- Use bullet points for clarity
- Keep descriptions concise
- Include both DO and DON'T examples
- Reference actual code over theoretical examples
- Use consistent formatting across rules

View File

@@ -0,0 +1,72 @@
---
description: Guidelines for continuously improving Roo Code rules based on emerging code patterns and best practices.
globs: **/*
alwaysApply: true
---
- **Rule Improvement Triggers:**
- New code patterns not covered by existing rules
- Repeated similar implementations across files
- Common error patterns that could be prevented
- New libraries or tools being used consistently
- Emerging best practices in the codebase
- **Analysis Process:**
- Compare new code with existing rules
- Identify patterns that should be standardized
- Look for references to external documentation
- Check for consistent error handling patterns
- Monitor test patterns and coverage
- **Rule Updates:**
- **Add New Rules When:**
- A new technology/pattern is used in 3+ files
- Common bugs could be prevented by a rule
- Code reviews repeatedly mention the same feedback
- New security or performance patterns emerge
- **Modify Existing Rules When:**
- Better examples exist in the codebase
- Additional edge cases are discovered
- Related rules have been updated
- Implementation details have changed
- **Example Pattern Recognition:**
```typescript
// If you see repeated patterns like:
const data = await prisma.user.findMany({
select: { id: true, email: true },
where: { status: 'ACTIVE' }
});
// Consider adding to [prisma.md](.roo/rules/prisma.md):
// - Standard select fields
// - Common where conditions
// - Performance optimization patterns
```
- **Rule Quality Checks:**
- Rules should be actionable and specific
- Examples should come from actual code
- References should be up to date
- Patterns should be consistently enforced
- **Continuous Improvement:**
- Monitor code review comments
- Track common development questions
- Update rules after major refactors
- Add links to relevant documentation
- Cross-reference related rules
- **Rule Deprecation:**
- Mark outdated patterns as deprecated
- Remove rules that no longer apply
- Update references to deprecated rules
- Document migration paths for old patterns
- **Documentation Updates:**
- Keep examples synchronized with code
- Update references to external docs
- Maintain links between related rules
- Document breaking changes
Follow [roo_rules.md](.roo/rules/roo_rules.md) for proper rule formatting and structure.

558
.roo/rules/taskmaster.md Normal file
View File

@@ -0,0 +1,558 @@
---
description: Comprehensive reference for Taskmaster MCP tools and CLI commands.
globs: **/*
alwaysApply: true
---
# Taskmaster Tool & Command Reference
This document provides a detailed reference for interacting with Taskmaster, covering both the recommended MCP tools, suitable for integrations like Roo Code, and the corresponding `task-master` CLI commands, designed for direct user interaction or fallback.
**Note:** For interacting with Taskmaster programmatically or via integrated tools, using the **MCP tools is strongly recommended** due to better performance, structured data, and error handling. The CLI commands serve as a user-friendly alternative and fallback.
**Important:** Several MCP tools involve AI processing... The AI-powered tools include `parse_prd`, `analyze_project_complexity`, `update_subtask`, `update_task`, `update`, `expand_all`, `expand_task`, and `add_task`.
**🏷️ Tagged Task Lists System:** Task Master now supports **tagged task lists** for multi-context task management. This allows you to maintain separate, isolated lists of tasks for different features, branches, or experiments. Existing projects are seamlessly migrated to use a default "master" tag. Most commands now support a `--tag <name>` flag to specify which context to operate on. If omitted, commands use the currently active tag.
---
## Initialization & Setup
### 1. Initialize Project (`init`)
* **MCP Tool:** `initialize_project`
* **CLI Command:** `task-master init [options]`
* **Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project.`
* **Key CLI Options:**
* `--name <name>`: `Set the name for your project in Taskmaster's configuration.`
* `--description <text>`: `Provide a brief description for your project.`
* `--version <version>`: `Set the initial version for your project, e.g., '0.1.0'.`
* `-y, --yes`: `Initialize Taskmaster quickly using default settings without interactive prompts.`
* **Usage:** Run this once at the beginning of a new project.
* **MCP Variant Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project by running the 'task-master init' command.`
* **Key MCP Parameters/Options:**
* `projectName`: `Set the name for your project.` (CLI: `--name <name>`)
* `projectDescription`: `Provide a brief description for your project.` (CLI: `--description <text>`)
* `projectVersion`: `Set the initial version for your project, e.g., '0.1.0'.` (CLI: `--version <version>`)
* `authorName`: `Author name.` (CLI: `--author <author>`)
* `skipInstall`: `Skip installing dependencies. Default is false.` (CLI: `--skip-install`)
* `addAliases`: `Add shell aliases tm and taskmaster. Default is false.` (CLI: `--aliases`)
* `yes`: `Skip prompts and use defaults/provided arguments. Default is false.` (CLI: `-y, --yes`)
* **Usage:** Run this once at the beginning of a new project, typically via an integrated tool like Roo Code. Operates on the current working directory of the MCP server.
* **Important:** Once complete, you *MUST* parse a prd in order to generate tasks. There will be no tasks files until then. The next step after initializing should be to create a PRD using the example PRD in .taskmaster/templates/example_prd.txt.
* **Tagging:** Use the `--tag` option to parse the PRD into a specific, non-default tag context. If the tag doesn't exist, it will be created automatically. Example: `task-master parse-prd spec.txt --tag=new-feature`.
### 2. Parse PRD (`parse_prd`)
* **MCP Tool:** `parse_prd`
* **CLI Command:** `task-master parse-prd [file] [options]`
* **Description:** `Parse a Product Requirements Document, PRD, or text file with Taskmaster to automatically generate an initial set of tasks in tasks.json.`
* **Key Parameters/Options:**
* `input`: `Path to your PRD or requirements text file that Taskmaster should parse for tasks.` (CLI: `[file]` positional or `-i, --input <file>`)
* `output`: `Specify where Taskmaster should save the generated 'tasks.json' file. Defaults to '.taskmaster/tasks/tasks.json'.` (CLI: `-o, --output <file>`)
* `numTasks`: `Approximate number of top-level tasks Taskmaster should aim to generate from the document.` (CLI: `-n, --num-tasks <number>`)
* `force`: `Use this to allow Taskmaster to overwrite an existing 'tasks.json' without asking for confirmation.` (CLI: `-f, --force`)
* **Usage:** Useful for bootstrapping a project from an existing requirements document.
* **Notes:** Task Master will strictly adhere to any specific requirements mentioned in the PRD, such as libraries, database schemas, frameworks, tech stacks, etc., while filling in any gaps where the PRD isn't fully specified. Tasks are designed to provide the most direct implementation path while avoiding over-engineering.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. If the user does not have a PRD, suggest discussing their idea and then use the example PRD in `.taskmaster/templates/example_prd.txt` as a template for creating the PRD based on their idea, for use with `parse-prd`.
---
## AI Model Configuration
### 2. Manage Models (`models`)
* **MCP Tool:** `models`
* **CLI Command:** `task-master models [options]`
* **Description:** `View the current AI model configuration or set specific models for different roles (main, research, fallback). Allows setting custom model IDs for Ollama and OpenRouter.`
* **Key MCP Parameters/Options:**
* `setMain <model_id>`: `Set the primary model ID for task generation/updates.` (CLI: `--set-main <model_id>`)
* `setResearch <model_id>`: `Set the model ID for research-backed operations.` (CLI: `--set-research <model_id>`)
* `setFallback <model_id>`: `Set the model ID to use if the primary fails.` (CLI: `--set-fallback <model_id>`)
* `ollama <boolean>`: `Indicates the set model ID is a custom Ollama model.` (CLI: `--ollama`)
* `openrouter <boolean>`: `Indicates the set model ID is a custom OpenRouter model.` (CLI: `--openrouter`)
* `listAvailableModels <boolean>`: `If true, lists available models not currently assigned to a role.` (CLI: No direct equivalent; CLI lists available automatically)
* `projectRoot <string>`: `Optional. Absolute path to the project root directory.` (CLI: Determined automatically)
* **Key CLI Options:**
* `--set-main <model_id>`: `Set the primary model.`
* `--set-research <model_id>`: `Set the research model.`
* `--set-fallback <model_id>`: `Set the fallback model.`
* `--ollama`: `Specify that the provided model ID is for Ollama (use with --set-*).`
* `--openrouter`: `Specify that the provided model ID is for OpenRouter (use with --set-*). Validates against OpenRouter API.`
* `--bedrock`: `Specify that the provided model ID is for AWS Bedrock (use with --set-*).`
* `--setup`: `Run interactive setup to configure models, including custom Ollama/OpenRouter IDs.`
* **Usage (MCP):** Call without set flags to get current config. Use `setMain`, `setResearch`, or `setFallback` with a valid model ID to update the configuration. Use `listAvailableModels: true` to get a list of unassigned models. To set a custom model, provide the model ID and set `ollama: true` or `openrouter: true`.
* **Usage (CLI):** Run without flags to view current configuration and available models. Use set flags to update specific roles. Use `--setup` for guided configuration, including custom models. To set a custom model via flags, use `--set-<role>=<model_id>` along with either `--ollama` or `--openrouter`.
* **Notes:** Configuration is stored in `.taskmaster/config.json` in the project root. This command/tool modifies that file. Use `listAvailableModels` or `task-master models` to see internally supported models. OpenRouter custom models are validated against their live API. Ollama custom models are not validated live.
* **API note:** API keys for selected AI providers (based on their model) need to exist in the mcp.json file to be accessible in MCP context. The API keys must be present in the local .env file for the CLI to be able to read them.
* **Model costs:** The costs in supported models are expressed in dollars. An input/output value of 3 is $3.00. A value of 0.8 is $0.80.
* **Warning:** DO NOT MANUALLY EDIT THE .taskmaster/config.json FILE. Use the included commands either in the MCP or CLI format as needed. Always prioritize MCP tools when available and use the CLI as a fallback.
---
## Task Listing & Viewing
### 3. Get Tasks (`get_tasks`)
* **MCP Tool:** `get_tasks`
* **CLI Command:** `task-master list [options]`
* **Description:** `List your Taskmaster tasks, optionally filtering by status and showing subtasks.`
* **Key Parameters/Options:**
* `status`: `Show only Taskmaster tasks matching this status (or multiple statuses, comma-separated), e.g., 'pending' or 'done,in-progress'.` (CLI: `-s, --status <status>`)
* `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`)
* `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Get an overview of the project status, often used at the start of a work session.
### 4. Get Next Task (`next_task`)
* **MCP Tool:** `next_task`
* **CLI Command:** `task-master next [options]`
* **Description:** `Ask Taskmaster to show the next available task you can work on, based on status and completed dependencies.`
* **Key Parameters/Options:**
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* `tag`: `Specify which tag context to use. Defaults to the current active tag.` (CLI: `--tag <name>`)
* **Usage:** Identify what to work on next according to the plan.
### 5. Get Task Details (`get_task`)
* **MCP Tool:** `get_task`
* **CLI Command:** `task-master show [id] [options]`
* **Description:** `Display detailed information for one or more specific Taskmaster tasks or subtasks by ID.`
* **Key Parameters/Options:**
* `id`: `Required. The ID of the Taskmaster task (e.g., '15'), subtask (e.g., '15.2'), or a comma-separated list of IDs ('1,5,10.2') you want to view.` (CLI: `[id]` positional or `-i, --id <id>`)
* `tag`: `Specify which tag context to get the task(s) from. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Understand the full details for a specific task. When multiple IDs are provided, a summary table is shown.
* **CRITICAL INFORMATION** If you need to collect information from multiple tasks, use comma-separated IDs (i.e. 1,2,3) to receive an array of tasks. Do not needlessly get tasks one at a time if you need to get many as that is wasteful.
---
## Task Creation & Modification
### 6. Add Task (`add_task`)
* **MCP Tool:** `add_task`
* **CLI Command:** `task-master add-task [options]`
* **Description:** `Add a new task to Taskmaster by describing it; AI will structure it.`
* **Key Parameters/Options:**
* `prompt`: `Required. Describe the new task you want Taskmaster to create, e.g., "Implement user authentication using JWT".` (CLI: `-p, --prompt <text>`)
* `dependencies`: `Specify the IDs of any Taskmaster tasks that must be completed before this new one can start, e.g., '12,14'.` (CLI: `-d, --dependencies <ids>`)
* `priority`: `Set the priority for the new task: 'high', 'medium', or 'low'. Default is 'medium'.` (CLI: `--priority <priority>`)
* `research`: `Enable Taskmaster to use the research role for potentially more informed task creation.` (CLI: `-r, --research`)
* `tag`: `Specify which tag context to add the task to. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Quickly add newly identified tasks during development.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 7. Add Subtask (`add_subtask`)
* **MCP Tool:** `add_subtask`
* **CLI Command:** `task-master add-subtask [options]`
* **Description:** `Add a new subtask to a Taskmaster parent task, or convert an existing task into a subtask.`
* **Key Parameters/Options:**
* `id` / `parent`: `Required. The ID of the Taskmaster task that will be the parent.` (MCP: `id`, CLI: `-p, --parent <id>`)
* `taskId`: `Use this if you want to convert an existing top-level Taskmaster task into a subtask of the specified parent.` (CLI: `-i, --task-id <id>`)
* `title`: `Required if not using taskId. The title for the new subtask Taskmaster should create.` (CLI: `-t, --title <title>`)
* `description`: `A brief description for the new subtask.` (CLI: `-d, --description <text>`)
* `details`: `Provide implementation notes or details for the new subtask.` (CLI: `--details <text>`)
* `dependencies`: `Specify IDs of other tasks or subtasks, e.g., '15' or '16.1', that must be done before this new subtask.` (CLI: `--dependencies <ids>`)
* `status`: `Set the initial status for the new subtask. Default is 'pending'.` (CLI: `-s, --status <status>`)
* `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after adding the subtask.` (CLI: `--skip-generate`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Break down tasks manually or reorganize existing tasks.
### 8. Update Tasks (`update`)
* **MCP Tool:** `update`
* **CLI Command:** `task-master update [options]`
* **Description:** `Update multiple upcoming tasks in Taskmaster based on new context or changes, starting from a specific task ID.`
* **Key Parameters/Options:**
* `from`: `Required. The ID of the first task Taskmaster should update. All tasks with this ID or higher that are not 'done' will be considered.` (CLI: `--from <id>`)
* `prompt`: `Required. Explain the change or new context for Taskmaster to apply to the tasks, e.g., "We are now using React Query instead of Redux Toolkit for data fetching".` (CLI: `-p, --prompt <text>`)
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Handle significant implementation changes or pivots that affect multiple future tasks. Example CLI: `task-master update --from='18' --prompt='Switching to React Query.\nNeed to refactor data fetching...'`
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 9. Update Task (`update_task`)
* **MCP Tool:** `update_task`
* **CLI Command:** `task-master update-task [options]`
* **Description:** `Modify a specific Taskmaster task by ID, incorporating new information or changes. By default, this replaces the existing task details.`
* **Key Parameters/Options:**
* `id`: `Required. The specific ID of the Taskmaster task, e.g., '15', you want to update.` (CLI: `-i, --id <id>`)
* `prompt`: `Required. Explain the specific changes or provide the new information Taskmaster should incorporate into this task.` (CLI: `-p, --prompt <text>`)
* `append`: `If true, appends the prompt content to the task's details with a timestamp, rather than replacing them. Behaves like update-subtask.` (CLI: `--append`)
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
* `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Refine a specific task based on new understanding. Use `--append` to log progress without creating subtasks.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 10. Update Subtask (`update_subtask`)
* **MCP Tool:** `update_subtask`
* **CLI Command:** `task-master update-subtask [options]`
* **Description:** `Append timestamped notes or details to a specific Taskmaster subtask without overwriting existing content. Intended for iterative implementation logging.`
* **Key Parameters/Options:**
* `id`: `Required. The ID of the Taskmaster subtask, e.g., '5.2', to update with new information.` (CLI: `-i, --id <id>`)
* `prompt`: `Required. The information, findings, or progress notes to append to the subtask's details with a timestamp.` (CLI: `-p, --prompt <text>`)
* `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`)
* `tag`: `Specify which tag context the subtask belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Log implementation progress, findings, and discoveries during subtask development. Each update is timestamped and appended to preserve the implementation journey.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 11. Set Task Status (`set_task_status`)
* **MCP Tool:** `set_task_status`
* **CLI Command:** `task-master set-status [options]`
* **Description:** `Update the status of one or more Taskmaster tasks or subtasks, e.g., 'pending', 'in-progress', 'done'.`
* **Key Parameters/Options:**
* `id`: `Required. The ID(s) of the Taskmaster task(s) or subtask(s), e.g., '15', '15.2', or '16,17.1', to update.` (CLI: `-i, --id <id>`)
* `status`: `Required. The new status to set, e.g., 'done', 'pending', 'in-progress', 'review', 'cancelled'.` (CLI: `-s, --status <status>`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Mark progress as tasks move through the development cycle.
### 12. Remove Task (`remove_task`)
* **MCP Tool:** `remove_task`
* **CLI Command:** `task-master remove-task [options]`
* **Description:** `Permanently remove a task or subtask from the Taskmaster tasks list.`
* **Key Parameters/Options:**
* `id`: `Required. The ID of the Taskmaster task, e.g., '5', or subtask, e.g., '5.2', to permanently remove.` (CLI: `-i, --id <id>`)
* `yes`: `Skip the confirmation prompt and immediately delete the task.` (CLI: `-y, --yes`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Permanently delete tasks or subtasks that are no longer needed in the project.
* **Notes:** Use with caution as this operation cannot be undone. Consider using 'blocked', 'cancelled', or 'deferred' status instead if you just want to exclude a task from active planning but keep it for reference. The command automatically cleans up dependency references in other tasks.
---
## Task Structure & Breakdown
### 13. Expand Task (`expand_task`)
* **MCP Tool:** `expand_task`
* **CLI Command:** `task-master expand [options]`
* **Description:** `Use Taskmaster's AI to break down a complex task into smaller, manageable subtasks. Appends subtasks by default.`
* **Key Parameters/Options:**
* `id`: `The ID of the specific Taskmaster task you want to break down into subtasks.` (CLI: `-i, --id <id>`)
* `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create. Uses complexity analysis/defaults otherwise.` (CLI: `-n, --num <number>`)
* `research`: `Enable Taskmaster to use the research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`)
* `prompt`: `Optional: Provide extra context or specific instructions to Taskmaster for generating the subtasks.` (CLI: `-p, --prompt <text>`)
* `force`: `Optional: If true, clear existing subtasks before generating new ones. Default is false (append).` (CLI: `--force`)
* `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Generate a detailed implementation plan for a complex task before starting coding. Automatically uses complexity report recommendations if available and `num` is not specified.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 14. Expand All Tasks (`expand_all`)
* **MCP Tool:** `expand_all`
* **CLI Command:** `task-master expand --all [options]` (Note: CLI uses the `expand` command with the `--all` flag)
* **Description:** `Tell Taskmaster to automatically expand all eligible pending/in-progress tasks based on complexity analysis or defaults. Appends subtasks by default.`
* **Key Parameters/Options:**
* `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create per task.` (CLI: `-n, --num <number>`)
* `research`: `Enable research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`)
* `prompt`: `Optional: Provide extra context for Taskmaster to apply generally during expansion.` (CLI: `-p, --prompt <text>`)
* `force`: `Optional: If true, clear existing subtasks before generating new ones for each eligible task. Default is false (append).` (CLI: `--force`)
* `tag`: `Specify which tag context to expand. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Useful after initial task generation or complexity analysis to break down multiple tasks at once.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 15. Clear Subtasks (`clear_subtasks`)
* **MCP Tool:** `clear_subtasks`
* **CLI Command:** `task-master clear-subtasks [options]`
* **Description:** `Remove all subtasks from one or more specified Taskmaster parent tasks.`
* **Key Parameters/Options:**
* `id`: `The ID(s) of the Taskmaster parent task(s) whose subtasks you want to remove, e.g., '15' or '16,18'. Required unless using 'all'.` (CLI: `-i, --id <ids>`)
* `all`: `Tell Taskmaster to remove subtasks from all parent tasks.` (CLI: `--all`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Used before regenerating subtasks with `expand_task` if the previous breakdown needs replacement.
### 16. Remove Subtask (`remove_subtask`)
* **MCP Tool:** `remove_subtask`
* **CLI Command:** `task-master remove-subtask [options]`
* **Description:** `Remove a subtask from its Taskmaster parent, optionally converting it into a standalone task.`
* **Key Parameters/Options:**
* `id`: `Required. The ID(s) of the Taskmaster subtask(s) to remove, e.g., '15.2' or '16.1,16.3'.` (CLI: `-i, --id <id>`)
* `convert`: `If used, Taskmaster will turn the subtask into a regular top-level task instead of deleting it.` (CLI: `-c, --convert`)
* `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after removing the subtask.` (CLI: `--skip-generate`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Delete unnecessary subtasks or promote a subtask to a top-level task.
### 17. Move Task (`move_task`)
* **MCP Tool:** `move_task`
* **CLI Command:** `task-master move [options]`
* **Description:** `Move a task or subtask to a new position within the task hierarchy.`
* **Key Parameters/Options:**
* `from`: `Required. ID of the task/subtask to move (e.g., "5" or "5.2"). Can be comma-separated for multiple tasks.` (CLI: `--from <id>`)
* `to`: `Required. ID of the destination (e.g., "7" or "7.3"). Must match the number of source IDs if comma-separated.` (CLI: `--to <id>`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Reorganize tasks by moving them within the hierarchy. Supports various scenarios like:
* Moving a task to become a subtask
* Moving a subtask to become a standalone task
* Moving a subtask to a different parent
* Reordering subtasks within the same parent
* Moving a task to a new, non-existent ID (automatically creates placeholders)
* Moving multiple tasks at once with comma-separated IDs
* **Validation Features:**
* Allows moving tasks to non-existent destination IDs (creates placeholder tasks)
* Prevents moving to existing task IDs that already have content (to avoid overwriting)
* Validates that source tasks exist before attempting to move them
* Maintains proper parent-child relationships
* **Example CLI:** `task-master move --from=5.2 --to=7.3` to move subtask 5.2 to become subtask 7.3.
* **Example Multi-Move:** `task-master move --from=10,11,12 --to=16,17,18` to move multiple tasks to new positions.
* **Common Use:** Resolving merge conflicts in tasks.json when multiple team members create tasks on different branches.
---
## Dependency Management
### 18. Add Dependency (`add_dependency`)
* **MCP Tool:** `add_dependency`
* **CLI Command:** `task-master add-dependency [options]`
* **Description:** `Define a dependency in Taskmaster, making one task a prerequisite for another.`
* **Key Parameters/Options:**
* `id`: `Required. The ID of the Taskmaster task that will depend on another.` (CLI: `-i, --id <id>`)
* `dependsOn`: `Required. The ID of the Taskmaster task that must be completed first, the prerequisite.` (CLI: `-d, --depends-on <id>`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <path>`)
* **Usage:** Establish the correct order of execution between tasks.
### 19. Remove Dependency (`remove_dependency`)
* **MCP Tool:** `remove_dependency`
* **CLI Command:** `task-master remove-dependency [options]`
* **Description:** `Remove a dependency relationship between two Taskmaster tasks.`
* **Key Parameters/Options:**
* `id`: `Required. The ID of the Taskmaster task you want to remove a prerequisite from.` (CLI: `-i, --id <id>`)
* `dependsOn`: `Required. The ID of the Taskmaster task that should no longer be a prerequisite.` (CLI: `-d, --depends-on <id>`)
* `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Update task relationships when the order of execution changes.
### 20. Validate Dependencies (`validate_dependencies`)
* **MCP Tool:** `validate_dependencies`
* **CLI Command:** `task-master validate-dependencies [options]`
* **Description:** `Check your Taskmaster tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.`
* **Key Parameters/Options:**
* `tag`: `Specify which tag context to validate. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Audit the integrity of your task dependencies.
### 21. Fix Dependencies (`fix_dependencies`)
* **MCP Tool:** `fix_dependencies`
* **CLI Command:** `task-master fix-dependencies [options]`
* **Description:** `Automatically fix dependency issues (like circular references or links to non-existent tasks) in your Taskmaster tasks.`
* **Key Parameters/Options:**
* `tag`: `Specify which tag context to fix dependencies in. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Clean up dependency errors automatically.
---
## Analysis & Reporting
### 22. Analyze Project Complexity (`analyze_project_complexity`)
* **MCP Tool:** `analyze_project_complexity`
* **CLI Command:** `task-master analyze-complexity [options]`
* **Description:** `Have Taskmaster analyze your tasks to determine their complexity and suggest which ones need to be broken down further.`
* **Key Parameters/Options:**
* `output`: `Where to save the complexity analysis report. Default is '.taskmaster/reports/task-complexity-report.json' (or '..._tagname.json' if a tag is used).` (CLI: `-o, --output <file>`)
* `threshold`: `The minimum complexity score (1-10) that should trigger a recommendation to expand a task.` (CLI: `-t, --threshold <number>`)
* `research`: `Enable research role for more accurate complexity analysis. Requires appropriate API key.` (CLI: `-r, --research`)
* `tag`: `Specify which tag context to analyze. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Used before breaking down tasks to identify which ones need the most attention.
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress.
### 23. View Complexity Report (`complexity_report`)
* **MCP Tool:** `complexity_report`
* **CLI Command:** `task-master complexity-report [options]`
* **Description:** `Display the task complexity analysis report in a readable format.`
* **Key Parameters/Options:**
* `tag`: `Specify which tag context to show the report for. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to the complexity report (default: '.taskmaster/reports/task-complexity-report.json').` (CLI: `-f, --file <file>`)
* **Usage:** Review and understand the complexity analysis results after running analyze-complexity.
---
## File Management
### 24. Generate Task Files (`generate`)
* **MCP Tool:** `generate`
* **CLI Command:** `task-master generate [options]`
* **Description:** `Create or update individual Markdown files for each task based on your tasks.json.`
* **Key Parameters/Options:**
* `output`: `The directory where Taskmaster should save the task files (default: in a 'tasks' directory).` (CLI: `-o, --output <directory>`)
* `tag`: `Specify which tag context to generate files for. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* **Usage:** Run this after making changes to tasks.json to keep individual task files up to date. This command is now manual and no longer runs automatically.
---
## AI-Powered Research
### 25. Research (`research`)
* **MCP Tool:** `research`
* **CLI Command:** `task-master research [options]`
* **Description:** `Perform AI-powered research queries with project context to get fresh, up-to-date information beyond the AI's knowledge cutoff.`
* **Key Parameters/Options:**
* `query`: `Required. Research query/prompt (e.g., "What are the latest best practices for React Query v5?").` (CLI: `[query]` positional or `-q, --query <text>`)
* `taskIds`: `Comma-separated list of task/subtask IDs from the current tag context (e.g., "15,16.2,17").` (CLI: `-i, --id <ids>`)
* `filePaths`: `Comma-separated list of file paths for context (e.g., "src/api.js,docs/readme.md").` (CLI: `-f, --files <paths>`)
* `customContext`: `Additional custom context text to include in the research.` (CLI: `-c, --context <text>`)
* `includeProjectTree`: `Include project file tree structure in context (default: false).` (CLI: `--tree`)
* `detailLevel`: `Detail level for the research response: 'low', 'medium', 'high' (default: medium).` (CLI: `--detail <level>`)
* `saveTo`: `Task or subtask ID (e.g., "15", "15.2") to automatically save the research conversation to.` (CLI: `--save-to <id>`)
* `saveFile`: `If true, saves the research conversation to a markdown file in '.taskmaster/docs/research/'.` (CLI: `--save-file`)
* `noFollowup`: `Disables the interactive follow-up question menu in the CLI.` (CLI: `--no-followup`)
* `tag`: `Specify which tag context to use for task-based context gathering. Defaults to the current active tag.` (CLI: `--tag <name>`)
* `projectRoot`: `The directory of the project. Must be an absolute path.` (CLI: Determined automatically)
* **Usage:** **This is a POWERFUL tool that agents should use FREQUENTLY** to:
* Get fresh information beyond knowledge cutoff dates
* Research latest best practices, library updates, security patches
* Find implementation examples for specific technologies
* Validate approaches against current industry standards
* Get contextual advice based on project files and tasks
* **When to Consider Using Research:**
* **Before implementing any task** - Research current best practices
* **When encountering new technologies** - Get up-to-date implementation guidance (libraries, apis, etc)
* **For security-related tasks** - Find latest security recommendations
* **When updating dependencies** - Research breaking changes and migration guides
* **For performance optimization** - Get current performance best practices
* **When debugging complex issues** - Research known solutions and workarounds
* **Research + Action Pattern:**
* Use `research` to gather fresh information
* Use `update_subtask` to commit findings with timestamps
* Use `update_task` to incorporate research into task details
* Use `add_task` with research flag for informed task creation
* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. The research provides FRESH data beyond the AI's training cutoff, making it invaluable for current best practices and recent developments.
---
## Tag Management
This new suite of commands allows you to manage different task contexts (tags).
### 26. List Tags (`tags`)
* **MCP Tool:** `list_tags`
* **CLI Command:** `task-master tags [options]`
* **Description:** `List all available tags with task counts, completion status, and other metadata.`
* **Key Parameters/Options:**
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
* `--show-metadata`: `Include detailed metadata in the output (e.g., creation date, description).` (CLI: `--show-metadata`)
### 27. Add Tag (`add_tag`)
* **MCP Tool:** `add_tag`
* **CLI Command:** `task-master add-tag <tagName> [options]`
* **Description:** `Create a new, empty tag context, or copy tasks from another tag.`
* **Key Parameters/Options:**
* `tagName`: `Name of the new tag to create (alphanumeric, hyphens, underscores).` (CLI: `<tagName>` positional)
* `--from-branch`: `Creates a tag with a name derived from the current git branch, ignoring the <tagName> argument.` (CLI: `--from-branch`)
* `--copy-from-current`: `Copy tasks from the currently active tag to the new tag.` (CLI: `--copy-from-current`)
* `--copy-from <tag>`: `Copy tasks from a specific source tag to the new tag.` (CLI: `--copy-from <tag>`)
* `--description <text>`: `Provide an optional description for the new tag.` (CLI: `-d, --description <text>`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
### 28. Delete Tag (`delete_tag`)
* **MCP Tool:** `delete_tag`
* **CLI Command:** `task-master delete-tag <tagName> [options]`
* **Description:** `Permanently delete a tag and all of its associated tasks.`
* **Key Parameters/Options:**
* `tagName`: `Name of the tag to delete.` (CLI: `<tagName>` positional)
* `--yes`: `Skip the confirmation prompt.` (CLI: `-y, --yes`)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
### 29. Use Tag (`use_tag`)
* **MCP Tool:** `use_tag`
* **CLI Command:** `task-master use-tag <tagName>`
* **Description:** `Switch your active task context to a different tag.`
* **Key Parameters/Options:**
* `tagName`: `Name of the tag to switch to.` (CLI: `<tagName>` positional)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
### 30. Rename Tag (`rename_tag`)
* **MCP Tool:** `rename_tag`
* **CLI Command:** `task-master rename-tag <oldName> <newName>`
* **Description:** `Rename an existing tag.`
* **Key Parameters/Options:**
* `oldName`: `The current name of the tag.` (CLI: `<oldName>` positional)
* `newName`: `The new name for the tag.` (CLI: `<newName>` positional)
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
### 31. Copy Tag (`copy_tag`)
* **MCP Tool:** `copy_tag`
* **CLI Command:** `task-master copy-tag <sourceName> <targetName> [options]`
* **Description:** `Copy an entire tag context, including all its tasks and metadata, to a new tag.`
* **Key Parameters/Options:**
* `sourceName`: `Name of the tag to copy from.` (CLI: `<sourceName>` positional)
* `targetName`: `Name of the new tag to create.` (CLI: `<targetName>` positional)
* `--description <text>`: `Optional description for the new tag.` (CLI: `-d, --description <text>`)
---
## Miscellaneous
### 32. Sync Readme (`sync-readme`) -- experimental
* **MCP Tool:** N/A
* **CLI Command:** `task-master sync-readme [options]`
* **Description:** `Exports your task list to your project's README.md file, useful for showcasing progress.`
* **Key Parameters/Options:**
* `status`: `Filter tasks by status (e.g., 'pending', 'done').` (CLI: `-s, --status <status>`)
* `withSubtasks`: `Include subtasks in the export.` (CLI: `--with-subtasks`)
* `tag`: `Specify which tag context to export from. Defaults to the current active tag.` (CLI: `--tag <name>`)
---
## Environment Variables Configuration (Updated)
Taskmaster primarily uses the **`.taskmaster/config.json`** file (in project root) for configuration (models, parameters, logging level, etc.), managed via `task-master models --setup`.
Environment variables are used **only** for sensitive API keys related to AI providers and specific overrides like the Ollama base URL:
* **API Keys (Required for corresponding provider):**
* `ANTHROPIC_API_KEY`
* `PERPLEXITY_API_KEY`
* `OPENAI_API_KEY`
* `GOOGLE_API_KEY`
* `MISTRAL_API_KEY`
* `AZURE_OPENAI_API_KEY` (Requires `AZURE_OPENAI_ENDPOINT` too)
* `OPENROUTER_API_KEY`
* `XAI_API_KEY`
* `OLLAMA_API_KEY` (Requires `OLLAMA_BASE_URL` too)
* **Endpoints (Optional/Provider Specific inside .taskmaster/config.json):**
* `AZURE_OPENAI_ENDPOINT`
* `OLLAMA_BASE_URL` (Default: `http://localhost:11434/api`)
**Set API keys** in your **`.env`** file in the project root (for CLI use) or within the `env` section of your **`.roo/mcp.json`** file (for MCP/Roo Code integration). All other settings (model choice, max tokens, temperature, log level, custom endpoints) are managed in `.taskmaster/config.json` via `task-master models` command or `models` MCP tool.
---
For details on how these commands fit into the development process, see the [dev_workflow.md](.roo/rules/dev_workflow.md).

63
.roomodes Normal file
View File

@@ -0,0 +1,63 @@
{
"customModes": [
{
"slug": "orchestrator",
"name": "Orchestrator",
"roleDefinition": "You are Roo, a strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes. You have a comprehensive understanding of each mode's capabilities and limitations, also your own, and with the information given by the user and other modes in shared context you are enabled to effectively break down complex problems into discrete tasks that can be solved by different specialists using the `taskmaster-ai` system for task and context management.",
"customInstructions": "Your role is to coordinate complex workflows by delegating tasks to specialized modes, using `taskmaster-ai` as the central hub for task definition, progress tracking, and context management. \nAs an orchestrator, you should:\nn1. When given a complex task, use contextual information (which gets updated frequently) to break it down into logical subtasks that can be delegated to appropriate specialized modes.\nn2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. \nThese instructions must include:\n* All necessary context from the parent task or previous subtasks required to complete the work.\n* A clearly defined scope, specifying exactly what the subtask should accomplish.\n* An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n* An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to further relay this information to other tasks and for you to keep track of what was completed on this project.\nn3. Track and manage the progress of all subtasks. When a subtask is completed, acknowledge its results and determine the next steps.\nn4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\nn5. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively. If it seems complex delegate to architect to accomplish that \nn6. Use subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.",
"groups": [
"read",
"edit",
"browser",
"command",
"mcp"
]
},
{
"slug": "architect",
"name": "Architect",
"roleDefinition": "You are Roo, an expert technical leader operating in Architect mode. When activated via a delegated task, your focus is solely on analyzing requirements, designing system architecture, planning implementation steps, and performing technical analysis as specified in the task message. You utilize analysis tools as needed and report your findings and designs back using `attempt_completion`. You do not deviate from the delegated task scope.",
"customInstructions": "1. Do some information gathering (for example using read_file or search_files) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, you should create a detailed plan for how to accomplish the task. Include Mermaid diagrams if they help make your plan clearer.\n\n4. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.\n\n5. Once the user confirms the plan, ask them if they'd like you to write it to a markdown file.\n\n6. Use the switch_mode tool to request that the user switch to another mode to implement the solution.",
"groups": [
"read",
["edit", { "fileRegex": "\\.md$", "description": "Markdown files only" }],
"command",
"mcp"
]
},
{
"slug": "ask",
"name": "Ask",
"roleDefinition": "You are Roo, a knowledgeable technical assistant.\nWhen activated by another mode via a delegated task, your focus is to research, analyze, and provide clear, concise answers or explanations based *only* on the specific information requested in the delegation message. Use available tools for information gathering and report your findings back using `attempt_completion`.",
"customInstructions": "You can analyze code, explain concepts, and access external resources. Make sure to answer the user's questions and don't rush to switch to implementing code. Include Mermaid diagrams if they help make your response clearer.",
"groups": [
"read",
"browser",
"mcp"
]
},
{
"slug": "debug",
"name": "Debug",
"roleDefinition": "You are Roo, an expert software debugger specializing in systematic problem diagnosis and resolution. When activated by another mode, your task is to meticulously analyze the provided debugging request (potentially referencing Taskmaster tasks, logs, or metrics), use diagnostic tools as instructed to investigate the issue, identify the root cause, and report your findings and recommended next steps back via `attempt_completion`. You focus solely on diagnostics within the scope defined by the delegated task.",
"customInstructions": "Reflect on 5-7 different possible sources of the problem, distill those down to 1-2 most likely sources, and then add logs to validate your assumptions. Explicitly ask the user to confirm the diagnosis before fixing the problem.",
"groups": [
"read",
"edit",
"command",
"mcp"
]
},
{
"slug": "test",
"name": "Test",
"roleDefinition": "You are Roo, an expert software tester. Your primary focus is executing testing tasks delegated to you by other modes.\nAnalyze the provided scope and context (often referencing a Taskmaster task ID and its `testStrategy`), develop test plans if needed, execute tests diligently, and report comprehensive results (pass/fail, bugs, coverage) back using `attempt_completion`. You operate strictly within the delegated task's boundaries.",
"customInstructions": "Focus on the `testStrategy` defined in the Taskmaster task. Develop and execute test plans accordingly. Report results clearly, including pass/fail status, bug details, and coverage information.",
"groups": [
"read",
"command",
"mcp"
]
}
]
}

1686
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,27 @@ all: help
# Define a variable for the test file path.
TEST_FILE ?= tests/
# Detect OS and set activation command
ifeq ($(OS),Windows_NT)
ACTIVATE = .venv\Scripts\activate
PYTHON = python
else
ACTIVATE = source .venv/bin/activate
PYTHON = python3
endif
test:
PYTHONPATH=src .venv/bin/coverage run --source=biz_bud -m pytest -n 4 tests/
.venv/bin/coverage report --show-missing
@bash -c "$(ACTIVATE) && PYTHONPATH=src coverage run --source=biz_bud -m pytest -n 4 tests/"
@bash -c "$(ACTIVATE) && coverage report --show-missing"
test_watch:
python -m ptw --snapshot-update --now . -- -vv tests/unit_tests
@bash -c "$(ACTIVATE) && python -m ptw --snapshot-update --now . -- -vv tests/unit_tests"
test_profile:
PYTHONPATH=. .venv/bin/coverage run -m pytest -vv tests/unit_tests/ --profile-svg
@bash -c "$(ACTIVATE) && PYTHONPATH=. coverage run -m pytest -vv tests/unit_tests/ --profile-svg"
extended_tests:
PYTHONPATH=. .venv/bin/coverage run -m pytest --only-extended $(TEST_FILE)
@bash -c "$(ACTIVATE) && PYTHONPATH=. coverage run -m pytest --only-extended $(TEST_FILE)"
######################
# SETUP COMMANDS
@@ -65,11 +74,11 @@ setup:
@echo "🐳 Starting Docker services..."
@$(MAKE) start
@echo "🐍 Creating Python virtual environment..."
@python3.12 -m venv .venv || python3 -m venv .venv
@$(PYTHON).12 -m venv .venv || $(PYTHON) -m venv .venv
@echo "📦 Installing Python dependencies with UV..."
@bash -c "source .venv/bin/activate && uv pip install -e '.[dev]'"
@bash -c "$(ACTIVATE) && uv pip install -e '.[dev]'"
@echo "🔗 Installing pre-commit hooks..."
@bash -c "source .venv/bin/activate && pre-commit install"
@bash -c "$(ACTIVATE) && pre-commit install"
@echo "✅ Setup complete! Next steps:"
@echo " 1. Add your API keys to .env file"
@echo " 2. Activate the environment: source .venv/bin/activate"
@@ -103,33 +112,57 @@ lint_tests: MYPY_CACHE=.mypy_cache_test
# Legacy lint targets - now use pre-commit
lint lint_diff lint_package lint_tests:
@echo "Running linting via pre-commit hooks..."
pre-commit run ruff --all-files
@bash -c "$(ACTIVATE) && pre-commit run ruff --all-files"
pyrefly:
pyrefly check .
@bash -c "$(ACTIVATE) && pyrefly check ."
# Run all linting and type checking via pre-commit
lint-all: pre-commit
@echo "\n🔍 Running additional mypy type checking..."
python -m mypy src/ --config-file mypy.ini || true
@echo "\n🔍 Running additional type checks..."
@bash -c "$(ACTIVATE) && pyrefly check . || true"
@bash -c "$(ACTIVATE) && ruff check . || true"
# Run pre-commit hooks (single source of truth for linting)
pre-commit:
@echo "🔧 Running pre-commit hooks..."
@echo "This includes: ruff (lint + format), pyrefly, codespell, and file checks"
pre-commit run --all-files
@bash -c "$(ACTIVATE) && pre-commit run --all-files"
# Format code using pre-commit
format format_diff:
@echo "Formatting code via pre-commit..."
pre-commit run ruff-format --all-files || true
pre-commit run ruff --all-files || true
@bash -c "$(ACTIVATE) && pre-commit run ruff-format --all-files || true"
@bash -c "$(ACTIVATE) && pre-commit run ruff --all-files || true"
spell_check:
codespell --toml pyproject.toml
@bash -c "$(ACTIVATE) && codespell --toml pyproject.toml"
spell_fix:
codespell --toml pyproject.toml -w
@bash -c "$(ACTIVATE) && codespell --toml pyproject.toml -w"
# Single file linting for hooks (expects FILE_PATH variable)
lint-file:
ifdef FILE_PATH
@echo "🔍 Linting $(FILE_PATH)..."
@bash -c "$(ACTIVATE) && pyrefly check '$(FILE_PATH)'"
@bash -c "$(ACTIVATE) && ruff check '$(FILE_PATH)' --fix"
@bash -c "$(ACTIVATE) && pyright '$(FILE_PATH)'"
@echo "✅ Linting complete"
else
@echo "❌ FILE_PATH not provided"
@exit 1
endif
black:
ifdef FILE_PATH
@echo "🔍 Formatting $(FILE_PATH)..."
@bash -c "$(ACTIVATE) && black '$(FILE_PATH)'"
@echo "✅ Formatting complete"
else
@echo "❌ FILE_PATH not provided"
@exit 1
endif
######################
# HELP
@@ -153,7 +186,7 @@ help:
@echo 'tree - show tree of .py files in src/'
coverage-report:
PYTHONPATH=. .venv/bin/coverage html && echo 'HTML report generated at htmlcov/index.html'
@bash -c "$(ACTIVATE) && PYTHONPATH=. coverage html && echo 'HTML report generated at htmlcov/index.html'"
tree:
tree -P '*.py' --prune -I '__pycache__' src/

View File

@@ -1,19 +1,27 @@
# Business Buddy Configuration
# ==============================================================================
# Business Buddy - Comprehensive Configuration
# ==============================================================================
#
# This file defines configuration values for the biz-budz project.
# Values set here can be overridden by environment variables.
# When a value is null or omitted, environment variables or Pydantic defaults will be used.
# This file defines all configuration values for the biz-budz project.
# It is reconciled against the Pydantic models in `src/biz_bud/config/schemas/`.
#
# Configuration precedence: Runtime arguments > Environment variables > config.yaml > Model defaults
# Configuration Precedence (highest to lowest):
# 1. Runtime arguments passed to a function/method.
# 2. Environment variables (e.g., OPENAI_API_KEY).
# 3. Values set in this `config.yaml` file.
# 4. Default values defined in the Pydantic models.
#
# Values commented out are typically set via environment variables for security.
# ---
# Default query and greeting messages
# Override with: DEFAULT_QUERY, DEFAULT_GREETING_MESSAGE
# Env Override: DEFAULT_QUERY, DEFAULT_GREETING_MESSAGE
DEFAULT_QUERY: "You are a helpful AI assistant. Please help me with my request."
DEFAULT_GREETING_MESSAGE: "Hello! I'm your AI assistant. How can I help you with your market research today?"
# Input state configuration (typically provided at runtime)
inputs:
# query: "Example query"
# query: "Example query" # A default query can be set here
# organization:
# - name: "Company Name"
# zip_code: "12345"
@@ -29,42 +37,39 @@ inputs:
subcategory:
- "Caribbean Food"
# ------------------------------------------------------------------------------
# SERVICE CONFIGURATIONS
# ------------------------------------------------------------------------------
# Logging configuration
# Override with: LOG_LEVEL
# Env Override: LOG_LEVEL
logging:
log_level: INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_level: INFO # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
# LLM profiles configuration
# Override profile settings with environment variables like:
# TINY_LLM_NAME, SMALL_LLM_NAME, LARGE_LLM_NAME, REASONING_LLM_NAME
# Env Override: e.g., TINY_LLM_NAME, LARGE_LLM_TEMPERATURE
llm_config:
tiny:
name: openai/gpt-4.1-nano
name: "openai/gpt-4.1-mini"
temperature: 0.7
# max_tokens: null # Uncomment to set, null for unlimited
input_token_limit: 100000
chunk_size: 4000
chunk_overlap: 200
small:
name: openai/gpt-4o
name: "openai/gpt-4o"
temperature: 0.7
input_token_limit: 100000
chunk_size: 4000
chunk_overlap: 200
large:
name: openai/gpt-4.1
name: "openai/gpt-4.1"
temperature: 0.7
input_token_limit: 100000
chunk_size: 4000
chunk_overlap: 200
reasoning:
name: openai/o4-mini
temperature: 0.7
input_token_limit: 100000
name: "openai/o3-mini"
input_token_limit: 65000
chunk_size: 4000
chunk_overlap: 200
@@ -72,203 +77,94 @@ llm_config:
agent_config:
max_loops: 5
recursion_limit: 1000 # LangGraph recursion limit for agent execution
default_llm_profile: large
default_initial_user_query: Hello
default_llm_profile: "large"
default_initial_user_query: "Hello"
# API configuration
# Override with environment variables:
# OPENAI_API_KEY, ANTHROPIC_API_KEY, FIREWORKS_API_KEY, etc.
# Env Override: OPENAI_API_KEY, ANTHROPIC_API_KEY, R2R_BASE_URL, etc.
api_config:
# openai_api_key: null # Set via OPENAI_API_KEY
# anthropic_api_key: null # Set via ANTHROPIC_API_KEY
# fireworks_api_key: null # Set via FIREWORKS_API_KEY
# openai_api_base: null # Set via OPENAI_API_BASE
# brave_api_key: null # Set via BRAVE_SEARCH_API_KEY
# brave_search_endpoint: null # Set via BRAVE_SEARCH_ENDPOINT
# brave_web_endpoint: null # Set via BRAVE_WEB_ENDPOINT
# brave_summarizer_endpoint: null # Set via BRAVE_SUMMARIZER_ENDPOINT
# brave_news_endpoint: null # Set via BRAVE_NEWS_ENDPOINT
# searxng_url: null # Set via SEARXNG_URL
# jina_api_key: null # Set via JINA_API_KEY
# langsmith_api_key: null # Set via LANGSMITH_API_KEY
# langsmith_project: null # Set via LANGSMITH_PROJECT
# langsmith_endpoint: null # Set via LANGSMITH_ENDPOINT
# r2r_api_key: null # Set via R2R_API_KEY
# r2r_base_url: null # Set via R2R_BASE_URL
# openai_api_key: null
# anthropic_api_key: null
# fireworks_api_key: null
# openai_api_base: null
# brave_api_key: null
# brave_search_endpoint: null
# brave_web_endpoint: null
# brave_summarizer_endpoint: null
# brave_news_endpoint: null
# searxng_url: null
# jina_api_key: null
# tavily_api_key: null
# langsmith_api_key: null
# langsmith_project: null
# langsmith_endpoint: null
# ragflow_api_key: null
# ragflow_base_url: null
# r2r_api_key: null
# r2r_base_url: null
# firecrawl_api_key: null
# firecrawl_base_url: null
# R2R Configuration
r2r:
default_collection: "business-buddy"
chunk_size: 1000
chunk_overlap: 200
extract_entities: true
extract_relationships: true
embedding_model: "openai/text-embedding-3-small"
search_limit: 10
use_hybrid_search: true
# Deduplication settings
deduplication:
enabled: true
check_by_url: true
check_by_title: true
similarity_threshold: 0.95
# Streaming settings
streaming:
enabled: true
progress_updates: true
batch_size: 5
timeout_seconds: 300
# Collection settings
collections:
auto_create: true
name_from_domain: true # Extract collection name from domain
default_description_template: "Documents from {domain}"
# firecrawl_api_key: null # Set via FIRECRAWL_API_KEY
# firecrawl_base_url: null # Set via FIRECRAWL_BASE_URL
# firecrawl_api_version: v1 # Set via FIRECRAWL_API_VERSION (v0, v1, or empty for unversioned)
# Database configuration
# Override with: QDRANT_HOST, QDRANT_PORT, POSTGRES_USER, etc.
# Database configuration (Postgres for structured data, Qdrant for vectors)
# Env Override: QDRANT_HOST, QDRANT_PORT, POSTGRES_USER, etc.
database_config:
# qdrant_host: null # Set via QDRANT_HOST
# qdrant_port: null # Set via QDRANT_PORT
# qdrant_collection_name: null # Set via QDRANT_COLLECTION_NAME
# postgres_user: null # Set via POSTGRES_USER
# postgres_password: null # Set via POSTGRES_PASSWORD
# postgres_db: null # Set via POSTGRES_DB
# postgres_host: null # Set via POSTGRES_HOST
# postgres_port: null # Set via POSTGRES_PORT
# qdrant_host: null
# qdrant_port: 6333
# qdrant_api_key: null
# qdrant_collection_name: "research"
# postgres_user: null
# postgres_password: null
# postgres_db: null
# postgres_host: null
# postgres_port: 5432
default_page_size: 100
max_page_size: 1000
# Proxy configuration
# Override with: PROXY_URL, OXYLABS_USERNAME, OXYLABS_PASSWORD
# Env Override: PROXY_URL, PROXY_USERNAME, PROXY_PASSWORD
proxy_config:
# proxy_url: null # Set via PROXY_URL
# proxy_username: null # Set via OXYLABS_USERNAME
# proxy_password: null # Set via OXYLABS_PASSWORD
# proxy_url: null
# proxy_username: null
# proxy_password: null
# Redis configuration
# Override with: REDIS_URL
redis_config:
# redis_url: redis://localhost:6379/0
# key_prefix: "biz_bud:"
# Env Override: REDIS_URL
# redis_config:
# redis_url: "redis://localhost:6379/0"
# key_prefix: "biz_bud:"
# Rate limits configuration
rate_limits:
# web_max_requests: null
# web_time_window: null
# llm_max_requests: null
# llm_time_window: null
# max_concurrent_connections: null
# max_connections_per_host: null
# ------------------------------------------------------------------------------
# WORKFLOW AND FEATURE CONFIGURATIONS
# ------------------------------------------------------------------------------
# Feature flags
feature_flags:
enable_advanced_reasoning: false
enable_streaming_response: true
enable_tool_caching: true
enable_parallel_tools: true
enable_memory_optimization: true
# experimental_features: {}
# RAG (Retrieval-Augmented Generation) configuration
rag_config:
crawl_depth: 2
use_crawl_endpoint: false # Use map+scrape for better discovery on documentation sites
use_firecrawl_extract: true
batch_size: 10
enable_semantic_chunking: true
chunk_size: 1000
chunk_overlap: 200
embedding_model: "openai/text-embedding-3-small"
skip_if_url_exists: true # New field: Skip processing if URL is already in R2R
reuse_existing_dataset: true # New field: Use existing R2R collection if found
custom_dataset_name: "business-buddy" # Custom name for R2R collection
max_pages_to_map: 2000 # Max pages to discover during URL mapping
max_pages_to_crawl: 2000 # Max pages to process after discovery (increased from default 20)
# extraction_prompt: null # Optional custom prompt for Firecrawl's extract feature
# Telemetry configuration
telemetry_config:
enable_telemetry: false
collect_performance_metrics: false
collect_usage_statistics: false
error_reporting_level: minimal # none, minimal, full
metrics_export_interval: 300
metrics_retention_days: 30
# custom_metrics: {}
# Tools configuration
tools:
search:
# name: null
# max_results: null
extract:
# name: null
web_tools:
scraper_timeout: 30
max_concurrent_scrapes: 5
max_concurrent_db_queries: 5
max_concurrent_analysis: 3
browser:
headless: true
timeout_seconds: 30.0
connection_timeout: 10
max_browsers: 3
browser_load_threshold: 10
max_scroll_percent: 500
# user_agent: null
viewport_width: 1920
viewport_height: 1080
network:
timeout: 30.0
max_retries: 3
follow_redirects: true
verify_ssl: true
# Search optimization configuration
search_optimization:
# Query optimization settings
query_optimization:
enable_deduplication: true
similarity_threshold: 0.85
max_results_multiplier: 3
max_results_limit: 10
max_providers_per_query: 3
max_query_merge_length: 150
min_shared_words_for_merge: 2
max_merged_query_words: 30
min_results_per_query: 3
# Concurrency settings
concurrency:
max_concurrent_searches: 10
provider_timeout_seconds: 10
provider_rate_limits:
tavily: 5
jina: 3
arxiv: 2
# Result ranking settings
ranking:
diversity_weight: 0.3
min_quality_score: 0.5
domain_frequency_weight: 0.8
domain_frequency_min_count: 2
freshness_decay_factor: 0.1
max_sources_to_return: 20
# Domain authority scores use comprehensive defaults
# Override specific domains if needed:
# domain_authority_scores:
# example.com: 0.9
# Caching settings
caching:
cache_ttl_seconds:
temporal: 3600 # 1 hour
factual: 604800 # 1 week
technical: 86400 # 1 day
default: 86400 # 1 day
lru_cache_size: 128
# Performance monitoring
enable_metrics: true
metrics_window_size: 1000
# Vector store configuration
vector_store_enhanced:
collection_name: "research"
embedding_model: "text-embedding-3-small"
namespace_prefix: "research"
vector_size: 1536
operation_timeout: 10
# Semantic extraction configuration
extraction:
model_name: openai/gpt-4o
model_name: "openai/gpt-4o"
chunk_size: 1000
chunk_overlap: 200
temperature: 0.2
@@ -284,23 +180,131 @@ extraction:
extract_claims: true
max_entities: 50
# Vector store configuration
vector_store_enhanced:
collection_name: research
embedding_model: text-embedding-3-small
namespace_prefix: research
similarity_threshold: 0.7
vector_size: 1536
operation_timeout: 10
# Search optimization configuration
search_optimization:
query_optimization:
enable_deduplication: true
similarity_threshold: 0.85
max_results_multiplier: 3
max_results_limit: 10
max_providers_per_query: 3
max_query_merge_length: 150
min_shared_words_for_merge: 2
max_merged_query_words: 30
min_results_per_query: 3
concurrency:
max_concurrent_searches: 10
provider_timeout_seconds: 10
provider_rate_limits:
tavily: 5
jina: 3
arxiv: 2
ranking:
diversity_weight: 0.3
min_quality_score: 0.5
domain_frequency_weight: 0.8
domain_frequency_min_count: 2
freshness_decay_factor: 0.1
max_sources_to_return: 20
# domain_authority_scores: # Override specific domains if needed
# "example.com": 0.9
caching:
cache_ttl_seconds:
temporal: 3600
factual: 604800
technical: 86400
default: 86400
lru_cache_size: 128
enable_metrics: true
metrics_window_size: 1000
# RAG configuration
rag_config:
max_pages_to_crawl: 20
crawl_depth: 2
use_crawl_endpoint: true
use_firecrawl_extract: false
batch_size: 10
enable_semantic_chunking: true
chunk_size: 1000
chunk_overlap: 200
# extraction_prompt: null
# Error Handling configuration (NEW SECTION)
error_handling:
max_retry_attempts: 3
retry_backoff_base: 1.5
retry_max_delay: 60
enable_llm_analysis: true
recovery_timeout: 300
enable_auto_recovery: true
# Define rules for classifying error severity
criticality_rules:
- type: "AuthenticationError"
severity: "critical"
- type: "ConfigurationError"
severity: "critical"
- category: "network"
severity: "high"
retryable: true
# Define recovery strategies for different error types
recovery_strategies:
rate_limit:
- action: "retry_with_backoff"
parameters: { backoff_base: 2.0, max_delay: 120 }
priority: 10
- action: "fallback"
parameters: { fallback_type: "provider" }
priority: 20
network:
- action: "retry_with_backoff"
parameters: { backoff_base: 1.5, max_delay: 60 }
priority: 10
# Tools configuration
tools:
search:
# name: null
# max_results: null
extract:
# name: null
web_tools:
scraper_timeout: 30
max_concurrent_scrapes: 5
max_concurrent_db_queries: 5
max_concurrent_analysis: 3
browser:
headless: true
timeout_seconds: 30.0
connection_timeout: 10
max_browsers: 3
browser_load_threshold: 10
max_scroll_percent: 500
# user_agent: null
viewport_width: 1920
viewport_height: 1080
network:
timeout: 30.0
max_retries: 3
follow_redirects: true
verify_ssl: true
# ------------------------------------------------------------------------------
# GENERAL APPLICATION SETTINGS
# ------------------------------------------------------------------------------
# Feature flags
feature_flags:
enable_advanced_reasoning: false
enable_streaming_response: true
enable_tool_caching: true
enable_parallel_tools: true
enable_memory_optimization: true
# experimental_features: {}
# Rate limits configuration
rate_limits:
# web_max_requests: null
# web_time_window: null
# llm_max_requests: null
# llm_time_window: null
# max_concurrent_connections: null
# max_connections_per_host: null
# Telemetry configuration
telemetry_config:
enable_telemetry: false
collect_performance_metrics: false
collect_usage_statistics: false
error_reporting_level: "minimal" # Options: none, minimal, full
metrics_export_interval: 300
metrics_retention_days: 30
# custom_metrics: {}

View File

@@ -1,654 +0,0 @@
665: ERROR Could not find import of `defusedxml.ElementTree` [import-error]
666: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/api_clients/arxiv.py:13:8
667: |
668: 13 | import defusedxml.ElementTree as ET
669: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
670: |
671: Looked in these locations (from config in `/home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/pyrefly.toml`):
672: Search path (from config file): ["/home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src"]
673: Import root (inferred from project layout): "/home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src"
674: Site package path (queried from interpreter at `/home/runner/work/biz-bud/biz-bud/.venv/bin/python`): ["/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12", "/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12/lib-dynload", "/home/runner/work/biz-bud/biz-bud/.venv/lib/python3.12/site-packages", "/home/runner/work/biz-bud/biz-bud/src"]
675: ERROR Argument `(cls: Self@ArxivSearchOptions, v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
676: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/api_clients/arxiv.py:315:5
677: |
678: 315 | @field_validator("max_results")
679: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
680: |
681: ERROR Argument `(cls: Self@ArxivSearchOptions, v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
682: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/api_clients/arxiv.py:322:5
683: |
684: 322 | @field_validator("start")
685: | ^^^^^^^^^^^^^^^^^^^^^^^^^
686: |
687: ERROR Argument `(cls: Self@ClassifierRequest, v: list[dict[str, str] | str]) -> list[dict[str, str] | str]` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
688: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/apis/jina/classifier.py:76:5
689: |
690: 76 | @field_validator("input")
691: | ^^^^^^^^^^^^^^^^^^^^^^^^^
692: |
693: ERROR Argument `(cls: Self@ClassifierRequest, v: list[str]) -> list[str]` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
694: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/apis/jina/classifier.py:85:5
695: |
696: 85 | @field_validator("labels")
697: | ^^^^^^^^^^^^^^^^^^^^^^^^^^
698: |
699: ERROR Argument `(cls: Self@GroundingRequest, v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
700: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/apis/jina/grounding.py:65:5
701: |
702: 65 | @field_validator("statement")
703: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
704: |
705: ERROR Argument `(cls: type[Self@SearchInputModel], v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
706: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/apis/jina/search.py:144:5
707: |
708: 144 | @field_validator("options")
709: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
710: |
711: ERROR Argument `(cls: type[Self@SearchInputModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
712: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/apis/jina/search.py:154:5
713: |
714: 154 | @field_validator("max_results")
715: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
716: |
717: ERROR Argument `(cls: type[Self@SearchResult], v: SourceType | str) -> SourceType` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
718: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/models.py:87:5
719: |
720: 87 | @field_validator("source", mode="before")
721: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
722: |
723: ERROR Argument `(cls: type[Self@ScrapedContent], v: int, info: ValidationInfo) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
724: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-tools/src/bb_tools/models.py:143:5
725: |
726: 143 | @field_validator("word_count")
727: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
728: |
729: ERROR Argument `(cls: type[Self@DatabaseConfig], v: SecretStr) -> SecretStr` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
730: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src/bb_utils/misc/config_manager.py:49:5
731: |
732: 49 | @field_validator("url")
733: | ^^^^^^^^^^^^^^^^^^^^^^^
734: |
735: ERROR Argument `(cls: type[Self@LLMConfig], v: SecretStr | None, info: ValidationInfo) -> SecretStr | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
736: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src/bb_utils/misc/config_manager.py:79:5
737: |
738: 79 | @field_validator("api_key")
739: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
740: |
741: ERROR Argument `(cls: type[Self@ApplicationConfig], v: bool, info: ValidationInfo) -> bool` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
742: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src/bb_utils/misc/config_manager.py:144:5
743: |
744: 144 | @field_validator("debug")
745: | ^^^^^^^^^^^^^^^^^^^^^^^^^
746: |
747: ERROR Could not find import of `psutil` [import-error]
748: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src/bb_utils/misc/dev_tools.py:42:12
749: |
750: 42 | import psutil # noqa: F401
751: | ^^^^^^
752: |
753: Looked in these locations (from config in `/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/pyrefly.toml`):
754: Search path (from config file): ["/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src"]
755: Import root (inferred from project layout): "/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src"
756: Site package path (queried from interpreter at `/home/runner/work/biz-bud/biz-bud/.venv/bin/python`): ["/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12", "/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12/lib-dynload", "/home/runner/work/biz-bud/biz-bud/.venv/lib/python3.12/site-packages", "/home/runner/work/biz-bud/biz-bud/src"]
757: ERROR Could not find import of `psutil` [import-error]
758: --> /home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src/bb_utils/misc/monitoring.py:840:16
759: |
760: 840 | import psutil
761: | ^^^^^^
762: |
763: Looked in these locations (from config in `/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/pyrefly.toml`):
764: Search path (from config file): ["/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src"]
765: Import root (inferred from project layout): "/home/runner/work/biz-bud/biz-bud/packages/business-buddy-utils/src"
766: Site package path (queried from interpreter at `/home/runner/work/biz-bud/biz-bud/.venv/bin/python`): ["/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12", "/opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12/lib-dynload", "/home/runner/work/biz-bud/biz-bud/.venv/lib/python3.12/site-packages", "/home/runner/work/biz-bud/biz-bud/src"]
767: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
768: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:92:5
769: |
770: 92 | @field_validator("max_tokens")
771: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
772: |
773: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
774: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:100:5
775: |
776: 100 | @field_validator("temperature")
777: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
778: |
779: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
780: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:108:5
781: |
782: 108 | @field_validator("input_token_limit")
783: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
784: |
785: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
786: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:116:5
787: |
788: 116 | @field_validator("chunk_size")
789: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
790: |
791: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
792: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:124:5
793: |
794: 124 | @field_validator("chunk_overlap")
795: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
796: |
797: ERROR Argument `(cls: type[Self@AgentConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
798: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:209:5
799: |
800: 209 | @field_validator("max_loops")
801: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
802: |
803: ERROR Argument `(cls: type[Self@LoggingConfig], v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
804: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:239:5
805: |
806: 239 | @field_validator("log_level")
807: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
808: |
809: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
810: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:325:5
811: |
812: 325 | @field_validator("qdrant_port")
813: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
814: |
815: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
816: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:352:5
817: |
818: 352 | @field_validator("postgres_port")
819: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
820: |
821: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
822: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:360:5
823: |
824: 360 | @field_validator("default_page_size")
825: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
826: |
827: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
828: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:368:5
829: |
830: 368 | @field_validator("max_page_size")
831: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
832: |
833: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
834: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:433:5
835: |
836: 433 | @field_validator("metrics_export_interval")
837: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
838: |
839: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
840: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:441:5
841: |
842: 441 | @field_validator("metrics_retention_days")
843: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
844: |
845: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
846: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:453:5
847: |
848: 453 | @field_validator("error_reporting_level")
849: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
850: |
851: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
852: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:481:5
853: |
854: 481 | @field_validator("web_max_requests")
855: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
856: |
857: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: float | None) -> float | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
858: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:489:5
859: |
860: 489 | @field_validator("web_time_window")
861: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
862: |
863: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
864: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:497:5
865: |
866: 497 | @field_validator("llm_max_requests")
867: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
868: |
869: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: float | None) -> float | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
870: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:505:5
871: |
872: 505 | @field_validator("llm_time_window")
873: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
874: |
875: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
876: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:513:5
877: |
878: 513 | @field_validator("max_concurrent_connections")
879: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
880: |
881: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
882: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:521:5
883: |
884: 521 | @field_validator("max_connections_per_host")
885: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
886: |
887: ERROR Argument `(cls: type[Self@SearchToolConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
888: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:541:5
889: |
890: 541 | @field_validator("max_results")
891: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
892: |
893: ERROR Argument `(cls: type[Self@BrowserConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
894: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:569:5
895: |
896: 569 | @field_validator("timeout_seconds")
897: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
898: |
899: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
900: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:577:5
901: |
902: 577 | @field_validator("connection_timeout")
903: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
904: |
905: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
906: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:585:5
907: |
908: 585 | @field_validator("max_browsers")
909: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
910: |
911: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
912: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:593:5
913: |
914: 593 | @field_validator("browser_load_threshold")
915: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
916: |
917: ERROR Argument `(cls: type[Self@NetworkConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
918: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:610:5
919: |
920: 610 | @field_validator("timeout")
921: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
922: |
923: ERROR Argument `(cls: type[Self@NetworkConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
924: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:618:5
925: |
926: 618 | @field_validator("max_retries")
927: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
928: |
929: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
930: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:647:5
931: |
932: 647 | @field_validator("scraper_timeout")
933: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
934: |
935: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
936: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:655:5
937: |
938: 655 | @field_validator("max_concurrent_scrapes")
939: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
940: |
941: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
942: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:663:5
943: |
944: 663 | @field_validator("max_concurrent_db_queries")
945: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
946: |
947: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
948: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:671:5
949: |
950: 671 | @field_validator("max_concurrent_analysis")
951: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
952: |
953: ERROR Argument `(cls: type[Self@QueryOptimizationSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
954: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:791:5
955: |
956: 791 | @field_validator("similarity_threshold")
957: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
958: |
959: ERROR Argument `(cls: type[Self@RankingSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
960: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:886:5
961: |
962: 886 | @field_validator("diversity_weight")
963: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
964: |
965: ERROR Argument `(cls: type[Self@RankingSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
966: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:894:5
967: |
968: 894 | @field_validator("min_quality_score")
969: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
970: |
971: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
972: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1093:5
973: |
974: 1093 | @field_validator("chunk_size")
975: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
976: |
977: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
978: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1101:5
979: |
980: 1101 | @field_validator("chunk_overlap")
981: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
982: |
983: ERROR Argument `(cls: type[Self@ExtractionConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
984: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1109:5
985: |
986: 1109 | @field_validator("temperature")
987: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
988: |
989: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
990: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1117:5
991: |
992: 1117 | @field_validator("max_content_length")
993: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
994: |
995: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
996: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1194:5
997: |
998: 1194 | @field_validator("max_pages_to_crawl")
999: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1000: |
1001: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1002: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1202:5
1003: |
1004: 1202 | @field_validator("crawl_depth")
1005: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1006: |
1007: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1008: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1210:5
1009: |
1010: 1210 | @field_validator("chunk_size")
1011: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1012: |
1013: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1014: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1218:5
1015: |
1016: 1218 | @field_validator("chunk_overlap")
1017: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1018: |
1019: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1020: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1270:5
1021: |
1022: 1270 | @field_validator("similarity_threshold")
1023: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1024: |
1025: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1026: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1278:5
1027: |
1028: 1278 | @field_validator("vector_size")
1029: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1030: |
1031: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1032: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/models.py:1286:5
1033: |
1034: 1286 | @field_validator("operation_timeout")
1035: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1036: |
1037: ERROR Argument `(cls: type[Self@AgentConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1038: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:24:5
1039: |
1040: 24 | @field_validator("max_loops")
1041: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1042: |
1043: ERROR Argument `(cls: type[Self@AgentConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1044: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:32:5
1045: |
1046: 32 | @field_validator("recursion_limit")
1047: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1048: |
1049: ERROR Argument `(cls: type[Self@LoggingConfig], v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1050: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:62:5
1051: |
1052: 62 | @field_validator("log_level")
1053: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1054: |
1055: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1056: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:116:5
1057: |
1058: 116 | @field_validator("metrics_export_interval")
1059: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1060: |
1061: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1062: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:124:5
1063: |
1064: 124 | @field_validator("metrics_retention_days")
1065: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1066: |
1067: ERROR Argument `(cls: type[Self@TelemetryConfigModel], v: str) -> str` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1068: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:136:5
1069: |
1070: 136 | @field_validator("error_reporting_level")
1071: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1072: |
1073: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1074: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:164:5
1075: |
1076: 164 | @field_validator("web_max_requests")
1077: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1078: |
1079: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: float | None) -> float | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1080: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:172:5
1081: |
1082: 172 | @field_validator("web_time_window")
1083: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1084: |
1085: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1086: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:180:5
1087: |
1088: 180 | @field_validator("llm_max_requests")
1089: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1090: |
1091: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: float | None) -> float | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1092: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:188:5
1093: |
1094: 188 | @field_validator("llm_time_window")
1095: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1096: |
1097: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1098: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:196:5
1099: |
1100: 196 | @field_validator("max_concurrent_connections")
1101: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1102: |
1103: ERROR Argument `(cls: type[Self@RateLimitConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1104: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/core.py:204:5
1105: |
1106: 204 | @field_validator("max_connections_per_host")
1107: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1108: |
1109: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1110: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/llm.py:38:5
1111: |
1112: 38 | @field_validator("max_tokens")
1113: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1114: |
1115: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1116: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/llm.py:46:5
1117: |
1118: 46 | @field_validator("temperature")
1119: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1120: |
1121: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1122: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/llm.py:54:5
1123: |
1124: 54 | @field_validator("input_token_limit")
1125: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1126: |
1127: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1128: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/llm.py:62:5
1129: |
1130: 62 | @field_validator("chunk_size")
1131: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1132: |
1133: ERROR Argument `(cls: type[Self@LLMProfileConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1134: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/llm.py:70:5
1135: |
1136: 70 | @field_validator("chunk_overlap")
1137: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1138: |
1139: ERROR Argument `(cls: type[Self@QueryOptimizationSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1140: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:39:5
1141: |
1142: 39 | @field_validator("similarity_threshold")
1143: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1144: |
1145: ERROR Argument `(cls: type[Self@RankingSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1146: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:134:5
1147: |
1148: 134 | @field_validator("diversity_weight")
1149: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1150: |
1151: ERROR Argument `(cls: type[Self@RankingSettings], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1152: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:142:5
1153: |
1154: 142 | @field_validator("min_quality_score")
1155: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1156: |
1157: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1158: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:338:5
1159: |
1160: 338 | @field_validator("chunk_size")
1161: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1162: |
1163: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1164: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:346:5
1165: |
1166: 346 | @field_validator("chunk_overlap")
1167: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1168: |
1169: ERROR Argument `(cls: type[Self@ExtractionConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1170: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:354:5
1171: |
1172: 354 | @field_validator("temperature")
1173: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1174: |
1175: ERROR Argument `(cls: type[Self@ExtractionConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1176: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:362:5
1177: |
1178: 362 | @field_validator("max_content_length")
1179: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1180: |
1181: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1182: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:424:5
1183: |
1184: 424 | @field_validator("max_pages_to_crawl")
1185: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1186: |
1187: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1188: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:432:5
1189: |
1190: 432 | @field_validator("crawl_depth")
1191: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1192: |
1193: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1194: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:440:5
1195: |
1196: 440 | @field_validator("chunk_size")
1197: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1198: |
1199: ERROR Argument `(cls: type[Self@RAGConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1200: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:448:5
1201: |
1202: 448 | @field_validator("chunk_overlap")
1203: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1204: |
1205: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1206: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:500:5
1207: |
1208: 500 | @field_validator("similarity_threshold")
1209: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1210: |
1211: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1212: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:508:5
1213: |
1214: 508 | @field_validator("vector_size")
1215: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1216: |
1217: ERROR Argument `(cls: type[Self@VectorStoreEnhancedConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1218: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/research.py:516:5
1219: |
1220: 516 | @field_validator("operation_timeout")
1221: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1222: |
1223: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1224: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/services.py:83:5
1225: |
1226: 83 | @field_validator("qdrant_port")
1227: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1228: |
1229: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1230: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/services.py:110:5
1231: |
1232: 110 | @field_validator("postgres_port")
1233: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1234: |
1235: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1236: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/services.py:118:5
1237: |
1238: 118 | @field_validator("default_page_size")
1239: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1240: |
1241: ERROR Argument `(cls: type[Self@DatabaseConfigModel], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1242: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/services.py:126:5
1243: |
1244: 126 | @field_validator("max_page_size")
1245: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1246: |
1247: ERROR Argument `(cls: type[Self@SearchToolConfigModel], v: int | None) -> int | None` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1248: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:14:5
1249: |
1250: 14 | @field_validator("max_results")
1251: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1252: |
1253: ERROR Argument `(cls: type[Self@BrowserConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1254: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:42:5
1255: |
1256: 42 | @field_validator("timeout_seconds")
1257: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1258: |
1259: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1260: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:50:5
1261: |
1262: 50 | @field_validator("connection_timeout")
1263: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1264: |
1265: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1266: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:58:5
1267: |
1268: 58 | @field_validator("max_browsers")
1269: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1270: |
1271: ERROR Argument `(cls: type[Self@BrowserConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1272: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:66:5
1273: |
1274: 66 | @field_validator("browser_load_threshold")
1275: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1276: |
1277: ERROR Argument `(cls: type[Self@NetworkConfig], v: float) -> float` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1278: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:83:5
1279: |
1280: 83 | @field_validator("timeout")
1281: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
1282: |
1283: ERROR Argument `(cls: type[Self@NetworkConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1284: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:91:5
1285: |
1286: 91 | @field_validator("max_retries")
1287: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1288: |
1289: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1290: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:120:5
1291: |
1292: 120 | @field_validator("scraper_timeout")
1293: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1294: |
1295: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1296: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:128:5
1297: |
1298: 128 | @field_validator("max_concurrent_scrapes")
1299: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1300: |
1301: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1302: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:136:5
1303: |
1304: 136 | @field_validator("max_concurrent_db_queries")
1305: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1306: |
1307: ERROR Argument `(cls: type[Self@WebToolsConfig], v: int) -> int` is not assignable to parameter with type `_V2BeforeAfterOrPlainValidatorType` [bad-argument-type]
1308: --> /home/runner/work/biz-bud/biz-bud/src/biz_bud/config/schemas/tools.py:144:5
1309: |
1310: 144 | @field_validator("max_concurrent_analysis")
1311: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1312: |
1313: INFO errors shown: 106, errors ignored: 50, modules: 257, transitive dependencies: 9,146, lines: 4,350,587, time: 32.35s, peak memory: physical 1.6 GiB
1314: codespell................................................................Failed
1315: - hook id: codespell
1316: - exit code: 64
1317: ERROR: cannot find ignore-words file: .codespellignore
1318: usage: codespell [-h] [--version] [-d] [-c] [-w] [-D DICTIONARY]

View File

@@ -0,0 +1,117 @@
# LangGraph Patterns Implementation Summary
## Overview
This document summarizes the implementation of LangGraph best practices across the Business Buddy codebase, following the guidance in CLAUDE.md.
## Key Patterns Implemented
### 1. State Immutability
- Created `bb_core.langgraph.state_immutability` module with:
- `ImmutableDict` class to prevent state mutations
- `StateUpdater` builder pattern for immutable state updates
- `@ensure_immutable_node` decorator to enforce immutability
### 2. Cross-Cutting Concerns
- Created `bb_core.langgraph.cross_cutting` module with decorators:
- `@log_node_execution` - Automatic logging of node execution
- `@track_metrics` - Performance metrics tracking
- `@handle_errors` - Standardized error handling
- `@retry_on_failure` - Retry logic with exponential backoff
- `@standard_node` - Composite decorator combining all concerns
### 3. RunnableConfig Integration
- Created `bb_core.langgraph.runnable_config` module with:
- `ConfigurationProvider` class for type-safe config access
- Helper functions for creating and merging RunnableConfig
- Support for service factory and metadata access
### 4. Graph Configuration
- Created `bb_core.langgraph.graph_config` module with:
- `configure_graph_with_injection` for dependency injection
- `create_config_injected_node` for automatic config injection
- Helper utilities for config extraction
### 5. Tool Decorator Pattern
- Updated tools to use `@tool` decorator from langchain_core.tools
- Added Pydantic schemas for input/output validation
- Examples in `scrapers.py` and `bb_extraction/tools.py`
### 6. Service Factory Pattern
- Created example in `service_factory_example.py` showing:
- Protocol-based service interfaces
- Abstract factory pattern
- Integration with RunnableConfig
## Updated Nodes
### Core Nodes
- `core/input.py` - StateUpdater, RunnableConfig, immutability
- `core/output.py` - Standard decorators, immutable updates
- `core/error.py` - Error handling with immutability
### Other Nodes Updated
- `synthesis/synthesize.py` - Using bb_core imports
- `scraping/url_router.py` - Standard node pattern
- `analysis/interpret.py` - Service factory from config
- `validation/logic.py` - Immutable state updates
- `extraction/orchestrator.py` - Standard decorators
- `search/orchestrator.py` - Cross-cutting concerns
- `llm/call.py` - ConfigurationProvider usage
## Example Implementations
### 1. Research Subgraph (`research_subgraph.py`)
Demonstrates:
- Type-safe state schema with TypedDict
- Tool implementation with @tool decorator
- Nodes with @standard_node and @ensure_immutable_node
- StateUpdater for all state mutations
- Error handling and logging
- Reusable subgraph pattern
### 2. Service Factory Example (`service_factory_example.py`)
Shows:
- Protocol-based service definitions
- Abstract factory pattern
- Integration with RunnableConfig
- Service caching and reuse
- Mock implementations for testing
## Migration Notes
### Renamed Files
The following local utility files were renamed to .old as they're replaced by bb_core:
- `src/biz_bud/utils/state_immutability.py.old`
- `src/biz_bud/utils/cross_cutting.py.old`
- `src/biz_bud/config/runnable_config.py.old`
### Test Updates
- Updated test files to pass `None` for the config parameter
- Example: `parse_and_validate_initial_payload(state, None)`
## Benefits Achieved
1. **State Predictability**: Immutable state prevents accidental mutations
2. **Consistent Logging**: All nodes have standardized logging
3. **Performance Monitoring**: Automatic metrics tracking
4. **Error Resilience**: Standardized error handling with retries
5. **Dependency Injection**: Clean separation of concerns via RunnableConfig
6. **Type Safety**: Strong typing throughout with no Any types
7. **Reusability**: Subgraphs and service factories enable code reuse
## Next Steps
1. Continue updating remaining nodes to use the new patterns
2. Create comprehensive tests for the new utilities
3. Update integration tests to work with immutable state
4. Create migration guide for existing code
5. Add more example subgraphs and patterns
## Code Quality
All implementations follow strict requirements:
- No `Any` types without bounds
- No `# type: ignore` comments
- Imperative docstrings with punctuation
- Direct file updates (no parallel versions)
- Utilities centralized in bb_core package

View File

@@ -0,0 +1,154 @@
# LangGraph Best Practices Implementation
This document summarizes the LangGraph best practices that have been implemented in the Business Buddy codebase.
## 1. Configuration Management with RunnableConfig
### Updated Files:
- **`src/biz_bud/nodes/llm/call.py`**: Updated to accept both `NodeLLMConfigOverride` and `RunnableConfig`
- **`src/biz_bud/nodes/core/input.py`**: Updated to accept `RunnableConfig` and use immutable state patterns
- **`src/biz_bud/config/runnable_config.py`**: Created `ConfigurationProvider` class for type-safe config access
### Key Features:
- Type-safe configuration access via `ConfigurationProvider`
- Support for both legacy dict-based config and new RunnableConfig pattern
- Automatic extraction of context (run_id, user_id) from RunnableConfig
- Seamless integration with existing service factory
## 2. State Immutability
### New Files:
- **`src/biz_bud/utils/state_immutability.py`**: Complete immutability utilities
- `ImmutableDict`: Prevents accidental state mutations
- `StateUpdater`: Builder pattern for immutable updates
- `@ensure_immutable_node`: Decorator to enforce immutability
- Helper functions for state validation
### Updated Files:
- **`src/biz_bud/nodes/core/input.py`**: Refactored to use `StateUpdater` for all state changes
### Key Patterns:
```python
# Instead of mutating state directly:
# state["key"] = value
# Use StateUpdater:
updater = StateUpdater(state)
new_state = (
updater
.set("key", value)
.append("list_key", item)
.build()
)
```
## 3. Service Factory Enhancements
### Existing Excellence:
- **`src/biz_bud/services/factory.py`**: Already follows best practices
- Singleton pattern within factory scope
- Thread-safe initialization
- Proper lifecycle management
- Dependency injection support
### New Files:
- **`src/biz_bud/services/web_tools.py`**: Factory functions for web tools
- `get_web_search_tool()`: Creates configured search tools
- `get_unified_scraper()`: Creates configured scrapers
- Automatic provider/strategy registration based on API keys
## 4. Tool Decorator Pattern
### New Files:
- **`src/biz_bud/tools/web_search_tool.py`**: Demonstrates @tool decorator pattern
- `@tool` decorated functions with proper schemas
- Input/output validation with Pydantic models
- Integration with RunnableConfig
- Error handling and fallback mechanisms
- Batch operations support
### Key Features:
- Type-safe tool definitions
- Automatic schema generation for LangGraph
- Support for both sync and async tools
- Proper error propagation
## 5. Cross-Cutting Concerns
### New Files:
- **`src/biz_bud/utils/cross_cutting.py`**: Comprehensive decorators for:
- **Logging**: `@log_node_execution` with context extraction
- **Metrics**: `@track_metrics` for performance monitoring
- **Error Handling**: `@handle_errors` with fallback support
- **Retries**: `@retry_on_failure` with exponential backoff
- **Composite**: `@standard_node` combining all concerns
### Usage Example:
```python
@standard_node(
node_name="my_node",
metric_name="my_metric",
retry_attempts=3
)
async def my_node(state: dict, config: RunnableConfig) -> dict:
# Automatically gets logging, metrics, error handling, and retries
pass
```
## 6. Reusable Subgraphs
### New Files:
- **`src/biz_bud/graphs/research_subgraph.py`**: Complete research workflow
- Demonstrates all best practices in a real workflow
- Input validation → Search → Synthesis → Summary
- Conditional routing based on state
- Proper error handling and metrics
- Can be embedded in larger graphs
### Key Patterns:
- Clear state schema definition (`ResearchState`)
- Immutable state updates throughout
- Tool integration with error handling
- Modular design for reusability
## 7. Graph Configuration Utilities
### New Files:
- **`src/biz_bud/utils/graph_config.py`**: Graph configuration helpers
- `configure_graph_with_injection()`: Auto-inject config into all nodes
- `create_config_injected_node()`: Wrap nodes with config injection
- `update_node_to_use_config()`: Decorator for config-aware nodes
- Backward compatibility helpers
## Integration Points
### Direct Updates (No Wrappers):
Following your directive, all updates were made directly to existing files rather than creating parallel versions:
- `call.py` enhanced with RunnableConfig support
- `input.py` refactored for immutability
- No `_v2` files created
### Clean Architecture:
- Clear separation of concerns
- Utilities in `utils/` directory
- Tools in `tools/` directory
- Service integration in `services/`
- Graph components in `graphs/`
## Next Steps
1. **Update Remaining Nodes**: Apply these patterns to other nodes in the codebase
2. **Add Tests**: Create comprehensive tests for new utilities
3. **Documentation**: Add inline documentation and usage examples
4. **Schema Updates**: Add missing config fields (research_config, tools_config) to AppConfig
5. **Migration Guide**: Create guide for updating existing nodes to new patterns
## Benefits Achieved
1. **Type Safety**: Full type checking with Pydantic and proper annotations
2. **Immutability**: Prevents accidental state mutations
3. **Observability**: Built-in logging and metrics
4. **Reliability**: Automatic retries and error handling
5. **Modularity**: Reusable components and subgraphs
6. **Maintainability**: Clear patterns and separation of concerns
7. **Performance**: Efficient state updates and service reuse

View File

@@ -1,106 +0,0 @@
# BizBud Critical Fixes - Executive Summary
## Overview
This folder contains the implementation plan and tools to address 8 critical issues identified in the BizBud code review. The fixes focus on removing redundancy, enforcing async patterns, and optimizing functionality without adding complexity.
## Documents in this folder
### 1. `critical-fixes-implementation-plan.md`
Comprehensive 5-day implementation plan covering:
- Async I/O fixes
- Exception handling improvements
- Redundancy removal
- Service injection patterns
- State management refactoring
- Pydantic v2 migration
- Performance optimizations
### 2. `claude-code-implementation-guide.md`
Step-by-step commands and code snippets for Claude Code to implement each fix:
- Exact file paths and search/replace patterns
- Shell commands for bulk updates
- New file templates
- Verification commands
- Common issues and solutions
### 3. `automated-fixes.py`
Python script that automatically applies safe, mechanical fixes:
- Pydantic v1 → v2 syntax updates
- Import statement corrections
- Compatibility code removal
- Backup file creation
- Change report generation
## Priority Issues
### 🔴 Critical (Day 1)
1. **Blocking I/O in Async Functions** - `config/loader.py` uses sync file operations
2. **Broad Exception Handling** - Catching generic `Exception` hides real errors
### 🟡 High (Day 2-3)
3. **Duplicate Error Systems** - 3 different error handling frameworks
4. **Duplicate Extraction Code** - Same code in bb_utils and bb_extraction
5. **Service Creation in Nodes** - Anti-pattern preventing proper testing
### 🟢 Medium (Day 4-5)
6. **Monolithic State Objects** - 50+ optional fields in single TypedDict
7. **Pydantic v1/v2 Mix** - Inconsistent API usage
8. **Complex URL Parsing** - 300+ lines for simple URL operations
## Quick Start
1. **Run automated fixes first**:
```bash
cd W:\home\vasceannie\repos\biz-budz
python docs/dev/opt/automated-fixes.py
```
2. **Review changes**:
```bash
# Check what changed
find . -name "*.bak" | head -20
# Run tests
pytest tests/ -v
```
3. **Apply manual fixes using the guides**:
- Follow `claude-code-implementation-guide.md` for specific changes
- Reference `critical-fixes-implementation-plan.md` for context
## Expected Outcomes
- **Code Reduction**: ~40% less code through deduplication
- **Performance**: All I/O operations truly async
- **Maintainability**: Single source of truth for errors, config, extraction
- **Testing**: Proper dependency injection enables unit testing
- **Type Safety**: Consistent Pydantic v2, focused state objects
## Risk Mitigation
All changes include:
- Automatic file backups before modification
- Incremental implementation phases
- Comprehensive testing after each phase
- No reverse compatibility baggage
- Clear rollback procedures
## Success Metrics
✅ Zero blocking I/O in async functions
✅ No broad exception catching
✅ Single error handling system
✅ No duplicate packages
✅ All services injected properly
✅ Consistent Pydantic v2 usage
✅ 50% reduction in URL parsing code
✅ 80% faster deduplication
## Next Steps
After implementing these fixes:
1. Update all unit tests to match new patterns
2. Run full integration test suite
3. Update developer documentation
4. Remove backup files after validation
5. Consider performance profiling for further optimizations

View File

@@ -1,15 +0,0 @@
# Automated Fixes Report
## Summary
- Total files backed up: 0
- Pydantic v2 migration: Completed
- Import fixes: Completed
- Compatibility code removal: Completed
## Backup Files Created
## Next Steps
1. Review the changes made by examining the .bak files
2. Run the test suite to ensure nothing broke
3. If tests pass, remove .bak files with: `find . -name "*.bak" -delete`
4. Proceed with manual fixes outlined in the implementation plan

View File

@@ -1,255 +0,0 @@
#!/usr/bin/env python3
"""BizBud Automated Fixes Script.
Run this script to automatically apply simple, safe fixes identified in the code review.
"""
import re
import shutil
import subprocess
from pathlib import Path
from typing import List, Tuple
# Project root
PROJECT_ROOT = Path("/home/vasceannie/repos/biz-budz")
def run_command(cmd: List[str], cwd: Path = PROJECT_ROOT) -> Tuple[int, str, str]:
"""Run a shell command and return exit code, stdout, stderr.
Args:
cmd: Command and arguments as a list of strings
cwd: Working directory for the command
Returns:
Tuple of (exit_code, stdout, stderr)
Raises:
ValueError: If command contains potentially dangerous characters
"""
# Validate command arguments for safety
if not cmd:
raise ValueError("Command cannot be empty")
# Check for shell injection attempts
dangerous_chars = [";", "&", "|", ">", "<", "$", "`", "\n", "\r"]
for arg in cmd:
if any(char in arg for char in dangerous_chars):
raise ValueError(
f"Potentially dangerous character in command argument: {arg}"
)
# Use subprocess.run with explicit list to avoid shell injection
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, shell=False)
return result.returncode, result.stdout, result.stderr
def backup_file(filepath: Path) -> None:
"""Create a backup of a file before modifying it."""
backup_path = filepath.with_suffix(filepath.suffix + ".bak")
shutil.copy2(filepath, backup_path)
def fix_pydantic_v2_migration() -> None:
"""Replace Pydantic v1 patterns with v2."""
# Find all Python files
py_files = list(PROJECT_ROOT.rglob("*.py"))
replacements = [
(r"\.dict\(\)", ".model_dump()"),
(r"\.parse_obj\(", ".model_validate("),
(r"parse_raw\(", "model_validate_json("),
(r"\.json\(\)", ".model_dump_json()"),
(r"schema\(\)", "model_json_schema()"),
(r"update_forward_refs\(\)", "model_rebuild()"),
]
files_modified = 0
for py_file in py_files:
if (
".bak" in str(py_file)
or "__pycache__" in str(py_file)
or ".venv" in str(py_file)
):
continue
try:
content = py_file.read_text()
original_content = content
# Skip if it's a TypedDict file
if "TypedDict" in content and ".dict()" in content:
continue
for pattern, replacement in replacements:
content = re.sub(pattern, replacement, content)
if content != original_content:
backup_file(py_file)
py_file.write_text(content)
files_modified += 1
except Exception:
pass
def fix_imports() -> None:
"""Fix import statements after package consolidation."""
import_fixes = [
# Error handling consolidation
(r"from bb_core\.errors import", "from bb_utils.core.unified_errors import"),
(
r"from bb_utils\.core\.error_handling import",
"from bb_utils.core.unified_errors import",
),
# Extraction consolidation
(
r"from bb_utils\.extraction\.statistics_extraction import",
"from bb_extraction.statistics_extraction import",
),
(
r"from bb_utils\.extraction\.company_extraction import",
"from bb_extraction.company_extraction import",
),
(
r"from bb_utils\.extraction\.text_utils import",
"from bb_extraction.text_utils import",
),
]
py_files = list(PROJECT_ROOT.rglob("*.py"))
files_modified = 0
for py_file in py_files:
if (
".bak" in str(py_file)
or "__pycache__" in str(py_file)
or ".venv" in str(py_file)
):
continue
try:
content = py_file.read_text()
original_content = content
for pattern, replacement in import_fixes:
content = re.sub(pattern, replacement, content)
if content != original_content:
backup_file(py_file)
py_file.write_text(content)
files_modified += 1
except Exception:
pass
def remove_compatibility_code() -> None:
"""Remove Pydantic v1/v2 compatibility code."""
compatibility_patterns = [
# Pattern to match compatibility checks
(
r'if hasattr\([^,]+,\s*[\'"]model_dump[\'"]\):\s*\n\s*.*model_dump\(\).*\n\s*else:\s*\n\s*.*dict\(\)',
lambda m: re.search(r"model_dump\(\)", m.group()).group(),
),
]
py_files = list(PROJECT_ROOT.rglob("*.py"))
files_modified = 0
for py_file in py_files:
if (
".bak" in str(py_file)
or "__pycache__" in str(py_file)
or ".venv" in str(py_file)
):
continue
try:
content = py_file.read_text()
original_content = content
# Remove compatibility patterns
for pattern, replacement in compatibility_patterns:
if callable(replacement):
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
else:
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
if content != original_content:
backup_file(py_file)
py_file.write_text(content)
files_modified += 1
except Exception:
pass
def create_missing_directories() -> None:
"""Create any missing directories for new files."""
dirs_to_create = [
PROJECT_ROOT / "src" / "biz_bud" / "graphs",
PROJECT_ROOT / "src" / "biz_bud" / "utils",
]
for dir_path in dirs_to_create:
if not dir_path.exists():
dir_path.mkdir(parents=True, exist_ok=True)
def generate_report() -> None:
"""Generate a report of changes made."""
report_path = PROJECT_ROOT / "docs" / "dev" / "opt" / "automated-fixes-report.md"
# Count backup files created (excluding .venv)
backup_files = [f for f in PROJECT_ROOT.rglob("*.bak") if ".venv" not in str(f)]
report_content = f"""# Automated Fixes Report
## Summary
- Total files backed up: {len(backup_files)}
- Pydantic v2 migration: Completed
- Import fixes: Completed
- Compatibility code removal: Completed
## Backup Files Created
"""
for backup in backup_files[:20]: # Show first 20
report_content += f"- {backup.relative_to(PROJECT_ROOT)}\n"
if len(backup_files) > 20:
report_content += f"- ... and {len(backup_files) - 20} more\n"
report_content += """
## Next Steps
1. Review the changes made by examining the .bak files
2. Run the test suite to ensure nothing broke
3. If tests pass, remove .bak files with: `find . -name "*.bak" -delete`
4. Proceed with manual fixes outlined in the implementation plan
"""
report_path.write_text(report_content)
def main() -> int:
"""Run all automated fixes."""
if not PROJECT_ROOT.exists():
return 1
# Create missing directories
create_missing_directories()
# Apply fixes
fix_pydantic_v2_migration()
fix_imports()
remove_compatibility_code()
# Generate report
generate_report()
return 0
if __name__ == "__main__":
exit(main())

View File

@@ -1,336 +0,0 @@
# Claude Code Implementation Guide for BizBud Fixes
## Quick Start Commands
### Phase 1: Async I/O Fix
```bash
# Install required dependency
cd W:\home\vasceannie\repos\biz-budz
pip install aiofiles
# Fix the config loader
# File: src/biz_bud/config/loader.py
```
**Search for**:
```python
async def load_config_async(
```
**Replace entire function with async version using aiofiles**
### Phase 2: Exception Handling Fix
```bash
# File: src/biz_bud/services/llm/client.py
```
**Add at top of file after imports**:
```python
# Define retriable exceptions
from httpx import RequestError, TimeoutException
import openai
import anthropic
RETRIABLE_EXCEPTIONS = (
RequestError,
TimeoutException,
openai.RateLimitError,
openai.APIConnectionError,
openai.APIStatusError,
anthropic.RateLimitError,
anthropic.APIConnectionError,
)
```
**Search for**: `except Exception as exc:`
**Replace with**: `except RETRIABLE_EXCEPTIONS as exc:`
### Phase 3: Remove Duplicate Error Systems
```bash
# Step 1: Remove old error systems
rm -rf packages/business-buddy-core/src/bb_core/errors/
rm packages/business-buddy-utils/src/bb_utils/core/error_handling.py
# Step 2: Update imports across codebase
# Run from project root
find . -name "*.py" -type f -exec grep -l "from.*bb_core.errors import" {} \; | while read file; do
sed -i 's/from bb_core.errors/from bb_utils.core.unified_errors/g' "$file"
done
find . -name "*.py" -type f -exec grep -l "from.*error_handling import" {} \; | while read file; do
sed -i 's/from bb_utils.core.error_handling/from bb_utils.core.unified_errors/g' "$file"
done
```
### Phase 4: Remove Duplicate Extraction Code
```bash
# Step 1: Backup unique features (if any)
cp -r packages/business-buddy-utils/src/bb_utils/extraction/ /tmp/extraction_backup/
# Step 2: Remove duplicate extraction modules
rm packages/business-buddy-utils/src/bb_utils/extraction/statistics_extraction.py
rm packages/business-buddy-utils/src/bb_utils/extraction/company_extraction.py
rm packages/business-buddy-utils/src/bb_utils/extraction/text_utils.py
# Step 3: Update imports
find . -name "*.py" -type f -exec sed -i 's/from bb_utils.extraction/from bb_extraction/g' {} \;
```
### Phase 5: Fix Service Injection
**Create new file**: `src/biz_bud/graphs/base_graph.py`
```python
from typing import TypedDict, Any, Dict
from langgraph.graph import StateGraph
from langgraph.graph.graph import CompiledGraph
from src.biz_bud.services.factory import ServiceFactory
from src.biz_bud.config.models import AppConfig
class GraphServices(TypedDict):
"""Services available to graph nodes."""
llm_client: Any
vector_store: Any
database: Any
web_search: Any
async def create_graph_with_services(
graph_builder: StateGraph,
app_config: AppConfig
) -> CompiledGraph:
"""Create graph with injected services."""
service_factory = ServiceFactory(app_config)
# Initialize all services
services = {
"llm_client": await service_factory.get_llm_client(),
"vector_store": await service_factory.get_vector_store(),
"database": await service_factory.get_postgres_store(),
"web_search": await service_factory.get_web_search()
}
# Compile graph
graph = graph_builder.compile()
# Return graph with services in config
return graph, services
```
**Update nodes to use injected services**:
Example for `src/biz_bud/graphs/research.py`:
**Search for**:
```python
service_factory = ServiceFactory(state["config"])
web_search = await service_factory.get_web_search()
```
**Replace with**:
```python
services = config.get("configurable", {}).get("services", {})
web_search = services.get("web_search")
if not web_search:
raise ValueError("Web search service not injected into graph config")
```
### Phase 6: Pydantic V2 Migration
```bash
# Step 1: Replace .dict() with .model_dump()
find . -name "*.py" -type f -exec sed -i 's/\.dict()/\.model_dump()/g' {} \;
# Step 2: Replace parse_obj with model_validate
find . -name "*.py" -type f -exec sed -i 's/\.parse_obj(/\.model_validate(/g' {} \;
# Step 3: Remove compatibility code
# Search for patterns like:
grep -r "hasattr.*model_dump" --include="*.py"
# Manually remove these compatibility checks
```
### Phase 7: Simplify URL Parsing
**Create new file**: `src/biz_bud/utils/url_parser.py`
```python
from urllib.parse import urlparse
from typing import Optional
import re
class URLParser:
"""Simplified URL parsing utilities."""
GIT_DOMAINS = {'github.com', 'gitlab.com', 'bitbucket.org'}
DOC_PATTERNS = {'docs.', 'documentation.', 'wiki.', '/docs/', '/wiki/'}
@staticmethod
def extract_domain(url: str) -> str:
"""Extract domain from URL."""
parsed = urlparse(url)
return parsed.netloc.lower()
@staticmethod
def get_url_type(url: str) -> str:
"""Determine URL type (git, docs, general)."""
domain = URLParser.extract_domain(url)
if any(git in domain for git in URLParser.GIT_DOMAINS):
return 'git'
elif any(pattern in url.lower() for pattern in URLParser.DOC_PATTERNS):
return 'docs'
else:
return 'general'
@staticmethod
def extract_meaningful_name(url: str) -> str:
"""Extract a meaningful name from URL."""
url_type = URLParser.get_url_type(url)
if url_type == 'git':
# Extract repo name from git URL
match = re.search(r'/([^/]+)/([^/]+?)(?:\.git)?$', url)
if match:
return f"{match.group(1)}_{match.group(2)}"
elif url_type == 'docs':
# Extract product/project name from docs
parsed = urlparse(url)
parts = parsed.netloc.split('.')
if len(parts) > 2 and parts[0] == 'docs':
return parts[1]
# Fallback: use domain
return URLParser.extract_domain(url).replace('.', '_')
```
**Update** `src/biz_bud/nodes/rag/upload_to_r2r.py`:
- Replace complex URL parsing functions with `URLParser` class methods
### Phase 8: Optimize Deduplication
**Create new file**: `src/biz_bud/utils/content_cache.py`
```python
import hashlib
from typing import Set, Optional
import json
class ContentDeduplicator:
"""Fast content deduplication using hashes."""
def __init__(self, redis_client=None):
self._content_hashes: Set[str] = set()
self._redis = redis_client
self._cache_key = "bizud:content:hashes"
async def initialize(self):
"""Load existing hashes from Redis if available."""
if self._redis:
stored = await self._redis.get(self._cache_key)
if stored:
self._content_hashes = set(json.loads(stored))
def is_duplicate(self, content: str, url: str) -> bool:
"""Check if content is duplicate using hash."""
# Create unique hash combining URL and content
content_hash = hashlib.sha256(
f"{url}:{content[:1000]}".encode() # Use first 1000 chars
).hexdigest()
if content_hash in self._content_hashes:
return True
self._content_hashes.add(content_hash)
return False
async def persist(self):
"""Persist hashes to Redis."""
if self._redis:
await self._redis.set(
self._cache_key,
json.dumps(list(self._content_hashes))
)
```
## Verification Commands
```bash
# Check for blocking I/O in async functions
grep -r "with open" --include="*.py" | grep -B2 "async def"
# Check for broad exception handling
grep -r "except Exception" --include="*.py"
# Check for duplicate imports
grep -r "from.*errors import" --include="*.py" | sort | uniq -d
# Check for Pydantic v1 usage
grep -r "\.dict()" --include="*.py" | grep -v "TypedDict"
grep -r "parse_obj" --include="*.py"
# Check for service instantiation in nodes
grep -r "ServiceFactory" src/biz_bud/nodes/ --include="*.py"
```
## Testing After Each Phase
```bash
# Run type checking
mypy src/biz_bud --strict
# Run unit tests for affected modules
pytest tests/ -k "test_config or test_llm or test_error"
# Run integration tests
pytest tests/integration/ -v
# Check for import errors
python -c "from biz_bud import *"
```
## Common Issues and Solutions
### Issue: Import errors after removing packages
**Solution**: Update `__init__.py` files to remove deleted module exports
### Issue: Tests fail after service injection
**Solution**: Update test fixtures to inject mock services:
```python
@pytest.fixture
async def mock_services():
return {
"llm_client": AsyncMock(),
"vector_store": AsyncMock(),
"database": AsyncMock(),
"web_search": AsyncMock()
}
```
### Issue: State type errors after breaking down TypedDict
**Solution**: Update node type hints to use specific state types:
```python
# Old
async def node(state: BusinessBuddyState) -> dict:
# New
async def research_node(state: ResearchState) -> dict:
```
## Final Cleanup
```bash
# Remove all TODO comments related to fixes
grep -r "TODO.*pydantic" --include="*.py" -l | xargs sed -i '/TODO.*pydantic/d'
grep -r "TODO.*compatibility" --include="*.py" -l | xargs sed -i '/TODO.*compatibility/d'
# Format all modified files
black src/ packages/ tests/
# Final type check
mypy src/ packages/ --strict
```

View File

@@ -1,347 +0,0 @@
# BizBud Critical Fixes Implementation Plan
## Overview
This document outlines the implementation plan to address critical issues identified in the code review. The fixes focus on removing redundancy, enforcing async patterns, and optimizing existing functionality without adding unnecessary complexity.
## Priority Order
### Phase 1: Foundation Fixes (Day 1-2)
#### 1.1 Fix Async I/O Patterns
**Issue**: Blocking I/O in async functions
**Files to Fix**:
- `src/biz_bud/config/loader.py`
**Implementation**:
```python
# Install aiofiles
# pip install aiofiles
# Update load_config_async to use async file operations
import aiofiles
import yaml
async def load_config_async(...) -> AppConfig:
if found_config_path:
try:
async with aiofiles.open(found_config_path, mode='r') as f:
content = await f.read()
yaml_config = yaml.safe_load(content) or {}
except (IOError, yaml.YAMLError) as e:
logger.error(f"Failed to read or parse config file {found_config_path}: {e}")
yaml_config = {}
```
#### 1.2 Fix Exception Handling
**Issue**: Overly broad exception catching
**Files to Fix**:
- `src/biz_bud/services/llm/client.py`
- All retry loops throughout the codebase
**Implementation**:
```python
# Define specific retriable exceptions
RETRIABLE_EXCEPTIONS = (
httpx.RequestError,
httpx.TimeoutException,
openai.RateLimitError,
openai.APIConnectionError,
openai.APIStatusError,
anthropic.RateLimitError,
# Add other provider-specific exceptions
)
# Update retry loops
try:
result = await self._call_model_lc(...)
except RETRIABLE_EXCEPTIONS as exc:
# Handle retry
except Exception as exc:
# Log and re-raise non-retriable exceptions
logger.error(f"Non-retriable error: {exc}")
raise
```
### Phase 2: Remove Redundancy (Day 2-3)
#### 2.1 Consolidate Error Handling
**Issue**: Multiple error handling systems
**Action**: Standardize on `bb_utils/core/unified_errors.py`
**Steps**:
1. Remove `packages/business-buddy-core/src/bb_core/errors/`
2. Remove `packages/business-buddy-utils/src/bb_utils/core/error_handling.py`
3. Update all imports to use unified_errors
4. Search and replace all error types:
```bash
# Find all error imports
grep -r "from.*errors import" --include="*.py"
grep -r "from.*error_handling import" --include="*.py"
```
#### 2.2 Consolidate Extraction Logic
**Issue**: Duplicate code between bb_extraction and bb_utils
**Action**: Keep only bb_extraction package
**Steps**:
1. Compare and merge unique features from both packages
2. Remove extraction modules from bb_utils:
- `bb_utils/extraction/statistics_extraction.py`
- `bb_utils/extraction/company_extraction.py`
- `bb_utils/extraction/text_utils.py`
3. Update all imports to use bb_extraction:
```bash
# Update imports
find . -name "*.py" -exec sed -i 's/from bb_utils.extraction/from bb_extraction/g' {} +
```
### Phase 3: Architectural Improvements (Day 3-4)
#### 3.1 Fix Service Injection Pattern
**Issue**: Services created inside nodes
**Action**: Implement proper dependency injection
**Implementation**:
1. Update graph initialization:
```python
# src/biz_bud/graphs/base_graph.py (create new file)
from typing import TypedDict, Any
from langgraph.graph import StateGraph
from src.biz_bud.services.factory import ServiceFactory
class GraphServices(TypedDict):
llm_client: Any
vector_store: Any
database: Any
web_search: Any
async def create_graph_with_services(
graph_builder: StateGraph,
app_config: AppConfig
) -> CompiledGraph:
"""Create graph with injected services."""
service_factory = ServiceFactory(app_config)
services = GraphServices(
llm_client=await service_factory.get_llm_client(),
vector_store=await service_factory.get_vector_store(),
database=await service_factory.get_postgres_store(),
web_search=await service_factory.get_web_search()
)
# Inject services into graph config
graph = graph_builder.compile()
graph.config = {"services": services}
return graph
```
2. Update nodes to use injected services:
```python
# Example node update
async def search_web_wrapper(state: ResearchState, config: RunnableConfig) -> dict:
# Remove service instantiation
# OLD: service_factory = ServiceFactory(state["config"])
# OLD: web_search = await service_factory.get_web_search()
# NEW: Get from config
services = config.get("configurable", {}).get("services", {})
web_search = services.get("web_search")
if not web_search:
raise ValueError("Web search service not injected")
# Rest of the node logic...
```
#### 3.2 Simplify State Management
**Issue**: Monolithic TypedDict with many optional fields
**Action**: Create focused state objects for each workflow
**Implementation**:
1. Break down BusinessBuddyState into smaller, focused states:
```python
# src/biz_bud/states/research_state.py
class ResearchState(TypedDict):
"""State specifically for research workflows."""
messages: Annotated[Sequence[AnyMessage], add_messages]
query: str
search_results: List[SearchResult]
extracted_content: List[ExtractedContent]
synthesis: Optional[str]
errors: List[ErrorInfo]
# src/biz_bud/states/analysis_state.py
class AnalysisState(TypedDict):
"""State specifically for analysis workflows."""
messages: Annotated[Sequence[AnyMessage], add_messages]
data_source: str
analysis_plan: Optional[AnalysisPlan]
results: Optional[AnalysisResults]
visualization: Optional[str]
errors: List[ErrorInfo]
```
2. Update nodes to use specific state types
### Phase 4: Code Cleanup (Day 4-5)
#### 4.1 Complete Pydantic V2 Migration
**Issue**: Mixed Pydantic v1/v2 usage
**Action**: Remove all v1 compatibility code
**Steps**:
1. Search and replace:
```bash
# Find all .dict() usage
grep -r "\.dict()" --include="*.py" | grep -v "TypedDict"
# Replace with .model_dump()
find . -name "*.py" -exec sed -i 's/\.dict()/\.model_dump()/g' {} +
# Find parse_obj usage
grep -r "parse_obj" --include="*.py"
# Replace with model_validate
find . -name "*.py" -exec sed -i 's/parse_obj/model_validate/g' {} +
```
2. Remove compatibility code:
```python
# Remove patterns like:
if hasattr(model, 'model_dump'):
data = model.model_dump()
else:
data = model.dict()
```
#### 4.2 Simplify URL Parsing
**Issue**: Overly complex URL parsing logic
**Action**: Refactor into focused functions
**Implementation**:
```python
# src/biz_bud/utils/url_parser.py (new file)
from urllib.parse import urlparse
from typing import Optional
class URLParser:
"""Simplified URL parsing utilities."""
@staticmethod
def extract_domain(url: str) -> str:
"""Extract domain from URL."""
parsed = urlparse(url)
return parsed.netloc.lower()
@staticmethod
def get_url_type(url: str) -> str:
"""Determine URL type (git, docs, general)."""
domain = URLParser.extract_domain(url)
if 'github.com' in domain or 'gitlab.com' in domain:
return 'git'
elif any(doc in domain for doc in ['docs.', 'documentation.', 'wiki.']):
return 'docs'
else:
return 'general'
@staticmethod
def extract_meaningful_name(url: str) -> str:
"""Extract a meaningful name from URL."""
url_type = URLParser.get_url_type(url)
if url_type == 'git':
return URLParser._extract_git_name(url)
elif url_type == 'docs':
return URLParser._extract_docs_name(url)
else:
return URLParser._extract_general_name(url)
```
### Phase 5: Performance Optimizations (Day 5)
#### 5.1 Optimize Deduplication
**Issue**: Inefficient deduplication in upload_to_r2r_node
**Action**: Implement hash-based caching
**Implementation**:
```python
# src/biz_bud/utils/content_cache.py (new file)
import hashlib
from typing import Set
class ContentDeduplicator:
"""Fast content deduplication using hashes."""
def __init__(self):
self._content_hashes: Set[str] = set()
def is_duplicate(self, content: str, url: str) -> bool:
"""Check if content is duplicate using hash."""
content_hash = hashlib.sha256(
f"{url}:{content}".encode()
).hexdigest()
if content_hash in self._content_hashes:
return True
self._content_hashes.add(content_hash)
return False
async def persist_to_redis(self, redis_client):
"""Persist hashes to Redis for distributed dedup."""
# Implementation for Redis persistence
```
## Implementation Checklist
### Day 1
- [ ] Fix async I/O in config loader
- [ ] Update exception handling in LLM client
- [ ] Create specific exception lists for each provider
### Day 2
- [ ] Remove duplicate error handling systems
- [ ] Consolidate extraction packages
- [ ] Update all imports
### Day 3
- [ ] Implement service injection pattern
- [ ] Create base graph with services
- [ ] Update all nodes to use injected services
### Day 4
- [ ] Break down monolithic state objects
- [ ] Complete Pydantic v2 migration
- [ ] Remove all compatibility code
### Day 5
- [ ] Simplify URL parsing logic
- [ ] Implement efficient deduplication
- [ ] Final testing and validation
## Testing Strategy
1. **Unit Tests**: Update all affected unit tests
2. **Integration Tests**: Test service injection and state management
3. **Performance Tests**: Verify async improvements and deduplication
## Success Metrics
- Zero blocking I/O in async functions
- No broad exception catching
- Single error handling system
- No duplicate code between packages
- All services injected, not instantiated in nodes
- Consistent Pydantic v2 usage
- 50% reduction in URL parsing code
- 80% faster deduplication
## Rollback Plan
1. Create feature branch for all changes
2. Implement changes incrementally
3. Run full test suite after each phase
4. Keep old code commented until validation complete
5. Final cleanup only after full validation

View File

@@ -1,259 +0,0 @@
# Testing Philosophy
## Core Principles
### 1. Type Safety First
```python
# ❌ BAD: No type annotations
def test_search():
result = search_node(state, config)
assert result is not None
# ✅ GOOD: Full type annotations
async def test_search_node() -> None:
state: ResearchState = TestDataFactory.create_research_state()
config: RunnableConfig = {"configurable": {"thread_id": "test-123"}}
result: Dict[str, Any] = await search_node(state, config)
assert "search_results" in result
```
**Guidelines:**
- Every test function must have type annotations
- All fixtures must return typed values
- No `Any` types unless absolutely necessary
- Use TypedDict for complex state objects
- Enable mypy strict mode in all test files
### 2. Test Isolation
Each test must be completely independent and isolated:
```python
# ❌ BAD: Tests depend on shared state
class TestService:
service = MyService() # Shared instance
def test_one(self):
self.service.do_something()
def test_two(self):
# May fail due to state from test_one
self.service.do_something_else()
# ✅ GOOD: Each test gets fresh instance
class TestService:
@pytest.fixture
def service(self) -> MyService:
return MyService()
def test_one(self, service: MyService) -> None:
service.do_something()
def test_two(self, service: MyService) -> None:
service.do_something_else()
```
### 3. Meaningful Coverage
Coverage is not just about percentages:
```python
# ❌ BAD: High coverage, low value
def test_trivial():
assert 1 + 1 == 2
assert True is True
# ✅ GOOD: Tests actual business logic
async def test_search_with_rate_limiting() -> None:
"""Verify search respects rate limits and retries appropriately."""
# Test actual behavior under constraints
```
**Coverage Guidelines:**
- Test business logic thoroughly
- Test error paths and edge cases
- Test async behavior and concurrency
- Skip trivial getters/setters
- Focus on behavior, not implementation
### 4. Clear Test Structure
Every test follows the Arrange-Act-Assert pattern:
```python
async def test_llm_retry_on_rate_limit() -> None:
"""Verify LLM client retries on rate limit errors."""
# --- Arrange ---
mock_llm = create_mock_llm()
mock_llm.side_effect = [
RateLimitError("Rate limited"),
"Success response"
]
client = LLMClient(mock_llm)
# --- Act ---
result = await client.complete("test prompt")
# --- Assert ---
assert result == "Success response"
assert mock_llm.call_count == 2
```
### 5. Descriptive Naming
Test names must clearly describe what they test:
```python
# ❌ BAD: Vague names
def test_search():
pass
def test_search_2():
pass
# ✅ GOOD: Descriptive names
def test_search_returns_empty_list_when_no_results():
pass
def test_search_filters_duplicate_urls():
pass
def test_search_respects_max_results_parameter():
pass
```
### 6. Mock at Boundaries
Mock external dependencies, not internal implementation:
```python
# ❌ BAD: Mocking internal methods
@patch("my_module.MyClass._internal_method")
def test_something(mock_method):
pass
# ✅ GOOD: Mock external dependencies
@patch("aiohttp.ClientSession.get")
def test_api_call(mock_get):
pass
```
### 7. Async-First Testing
Use async tests for async code:
```python
# ❌ BAD: Synchronous test for async code
def test_async_function():
result = asyncio.run(my_async_function())
assert result == expected
# ✅ GOOD: Async test
@pytest.mark.asyncio
async def test_async_function():
result = await my_async_function()
assert result == expected
```
### 8. Performance Awareness
Tests should be fast but thorough:
```python
# Use markers for slow tests
@pytest.mark.slow
async def test_complex_workflow():
"""This test takes >5 seconds due to multiple operations."""
pass
# Parallelize where possible
@pytest.mark.parametrize("input_data", test_cases)
async def test_multiple_scenarios(input_data):
pass
```
### 9. Documentation as Tests
Test names and docstrings serve as documentation:
```python
async def test_search_caches_results_for_identical_queries() -> None:
"""
Verify that the search system caches results when the same query
is executed multiple times within the cache TTL window.
This test ensures:
1. First query hits the search API
2. Second identical query returns cached result
3. Cache metrics are properly updated
"""
# Test implementation
```
### 10. Error Testing
Always test error conditions:
```python
async def test_search_handles_api_timeout() -> None:
"""Verify graceful handling when search API times out."""
# Arrange
mock_search = AsyncMock(side_effect=asyncio.TimeoutError())
# Act & Assert
with pytest.raises(SearchTimeoutError) as exc_info:
await search_with_timeout(mock_search, timeout=1.0)
assert "Search timed out after 1.0 seconds" in str(exc_info.value)
```
## Testing Levels Philosophy
### Unit Tests
- **Purpose**: Verify individual components work correctly in isolation
- **Scope**: Single function/method/class
- **Dependencies**: All mocked
- **Speed**: <1 second per test
- **Coverage Target**: 95% for business logic
### Integration Tests
- **Purpose**: Verify components work together correctly
- **Scope**: Multiple components within a package
- **Dependencies**: External services mocked, internal real
- **Speed**: 1-5 seconds per test
- **Coverage Target**: 85% for interaction paths
### Workflow Tests
- **Purpose**: Verify complete business flows work end-to-end
- **Scope**: Entire graph/workflow execution
- **Dependencies**: Only external APIs mocked
- **Speed**: 5-30 seconds per test
- **Coverage Target**: Core business workflows
## Anti-Patterns to Avoid
1. **Testing Implementation Details**
- Don't test private methods directly
- Don't assert on internal state
- Focus on public interfaces
2. **Brittle Tests**
- Avoid exact string matching when substring works
- Don't rely on ordering unless required
- Use data factories instead of hardcoded values
3. **Test Interdependence**
- Never rely on test execution order
- Don't share state between tests
- Each test must be runnable independently
4. **Over-Mocking**
- Don't mock everything
- Keep some integration between components
- Mock at service boundaries
5. **Meaningless Assertions**
- Every assertion should test business value
- Avoid testing framework behavior
- Focus on outcomes, not mechanics

View File

@@ -1,369 +0,0 @@
# Testing Architecture Overview
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Test Suite │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Unit Tests │ │ Integration │ │ Workflow Tests │ │
│ │ (Fast) │ │ Tests │ │ (End-to-End) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Fixture Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Config │ │ Service │ │ State │ │
│ │ Fixtures │ │ Mocks │ │ Factories │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Helper Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Data │ │ Async │ │ Assertion │ │
│ │ Factories │ │ Utilities │ │ Helpers │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Component Architecture
### 1. Test Organization
```
tests/
├── conftest.py # Root fixtures
├── pytest.ini # Pytest configuration
├── helpers/ # Shared test utilities
│ ├── __init__.py
│ ├── factories.py # Data creation factories
│ ├── fixtures.py # Reusable fixtures
│ ├── mocks.py # Mock implementations
│ └── assertions.py # Custom assertions
├── unit/ # Unit tests
│ ├── conftest.py # Unit-specific fixtures
│ ├── biz_bud/ # Mirrors src/biz_bud
│ └── packages/ # Mirrors packages/
├── integration/ # Integration tests
│ ├── conftest.py # Integration-specific fixtures
│ └── cross_package/ # Cross-package tests
└── workflow/ # End-to-end tests
├── conftest.py # Workflow-specific fixtures
└── graphs/ # Graph workflow tests
```
### 2. Fixture Architecture
```python
# Hierarchical fixture structure
@pytest.fixture(scope="session")
def base_config() -> AppConfig:
"""Session-wide base configuration."""
return AppConfig.model_validate({})
@pytest.fixture(scope="function")
def config(base_config: AppConfig) -> AppConfig:
"""Function-scoped config that can be modified."""
return base_config.model_copy(deep=True)
@pytest.fixture
def mock_llm_client(config: AppConfig) -> Mock:
"""LLM client mock configured with test config."""
mock = create_autospec(LangchainLLMClient, spec_set=True)
mock.config = config
return mock
@pytest.fixture
async def service_factory(
config: AppConfig,
mock_llm_client: Mock,
mock_vector_store: Mock,
) -> AsyncGenerator[ServiceFactory, None]:
"""Fully mocked service factory."""
factory = ServiceFactory(config)
# Override service creation
factory._services = {
LangchainLLMClient: mock_llm_client,
VectorStore: mock_vector_store,
}
yield factory
await factory.cleanup()
```
### 3. Mock Architecture
```python
# Type-safe mock creation
from typing import TypeVar, Type
from unittest.mock import Mock, AsyncMock, create_autospec
T = TypeVar('T')
def create_mock_service(
service_class: Type[T],
config: AppConfig,
**kwargs
) -> Mock:
"""Create a properly typed mock of a service."""
mock = create_autospec(service_class, spec_set=True, **kwargs)
mock.config = config
# Add async method support
for attr_name in dir(service_class):
attr = getattr(service_class, attr_name, None)
if asyncio.iscoroutinefunction(attr):
setattr(mock, attr_name, AsyncMock())
return mock
```
### 4. State Factory Architecture
```python
# Flexible state creation with builder pattern
class StateBuilder:
"""Builder for creating test states with sensible defaults."""
def __init__(self):
self._state = self._default_state()
def _default_state(self) -> Dict[str, Any]:
return {
"messages": [],
"errors": [],
"config": {},
"status": "pending",
"thread_id": f"test-{uuid.uuid4()}",
}
def with_messages(self, *messages: BaseMessage) -> "StateBuilder":
self._state["messages"].extend(messages)
return self
def with_error(self, error: ErrorInfo) -> "StateBuilder":
self._state["errors"].append(error)
return self
def with_config(self, **config_overrides) -> "StateBuilder":
self._state["config"].update(config_overrides)
return self
def build(self, state_class: Type[TypedDict]) -> TypedDict:
return state_class(**self._state)
```
## Testing Patterns
### 1. Async Testing Pattern
```python
# Consistent async test pattern
@pytest.mark.asyncio
class TestAsyncService:
@pytest.fixture
async def service(self, config: AppConfig) -> AsyncGenerator[MyService, None]:
service = MyService(config)
await service.initialize()
yield service
await service.cleanup()
async def test_async_operation(self, service: MyService) -> None:
result = await service.async_operation()
assert result.status == "success"
```
### 2. Parametrized Testing Pattern
```python
# Data-driven testing
@pytest.mark.parametrize(
"input_query,expected_queries",
[
("simple query", ["simple query"]),
("complex query", ["complex", "query", "complex query"]),
("", []),
],
ids=["simple", "complex", "empty"]
)
async def test_query_expansion(
input_query: str,
expected_queries: List[str],
query_expander: QueryExpander
) -> None:
result = await query_expander.expand(input_query)
assert result == expected_queries
```
### 3. Error Testing Pattern
```python
# Comprehensive error testing
class TestErrorHandling:
@pytest.mark.parametrize(
"exception_class,expected_error_type",
[
(RateLimitError, "rate_limit"),
(TimeoutError, "timeout"),
(ValueError, "validation"),
]
)
async def test_error_classification(
self,
exception_class: Type[Exception],
expected_error_type: str,
error_handler: ErrorHandler
) -> None:
with pytest.raises(BusinessBuddyError) as exc_info:
raise exception_class("Test error")
error_info = error_handler.classify_error(exc_info.value)
assert error_info.error_type == expected_error_type
```
### 4. Mock Verification Pattern
```python
# Detailed mock verification
async def test_service_interaction() -> None:
# Arrange
mock_db = AsyncMock(spec=DatabaseService)
mock_cache = AsyncMock(spec=CacheService)
service = DataService(mock_db, mock_cache)
# Configure mock behavior
mock_cache.get.return_value = None # Cache miss
mock_db.fetch.return_value = {"id": 1, "data": "test"}
# Act
result = await service.get_data(1)
# Assert - Verify interactions
mock_cache.get.assert_called_once_with("data:1")
mock_db.fetch.assert_called_once_with(1)
mock_cache.set.assert_called_once_with(
"data:1",
{"id": 1, "data": "test"},
ttl=3600
)
assert result == {"id": 1, "data": "test"}
```
## Coverage Architecture
### 1. Coverage Configuration
```toml
# pyproject.toml
[tool.coverage.run]
source = ["src", "packages"]
branch = true
concurrency = ["multiprocessing", "thread"]
parallel = true
context = '${CONTEXT}'
[tool.coverage.report]
fail_under = 90
show_missing = true
skip_covered = false
skip_empty = true
precision = 2
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"@abstractmethod",
"raise NotImplementedError",
]
[tool.coverage.html]
directory = "coverage_html"
show_contexts = true
```
### 2. Coverage by Component
```yaml
coverage_targets:
core_business_logic:
target: 95%
paths:
- src/biz_bud/nodes/**
- src/biz_bud/services/**
utilities:
target: 90%
paths:
- packages/business-buddy-utils/**
configuration:
target: 85%
paths:
- src/biz_bud/config/**
experimental:
target: 70%
paths:
- src/biz_bud/experimental/**
```
## Performance Architecture
### 1. Test Performance Tiers
```python
# Mark tests by performance tier
@pytest.mark.fast # <0.1s
async def test_simple_validation():
pass
@pytest.mark.normal # 0.1s - 1s
async def test_service_operation():
pass
@pytest.mark.slow # 1s - 10s
async def test_complex_workflow():
pass
@pytest.mark.very_slow # >10s
async def test_full_graph_execution():
pass
```
### 2. Parallel Execution
```ini
# pytest.ini
[pytest]
# Enable parallel execution
addopts = -n auto --dist loadgroup
# Group related tests
markers =
parallel_group1: First parallel group
parallel_group2: Second parallel group
```
## Continuous Integration Architecture
### 1. Test Pipeline Stages
```yaml
# .github/workflows/test.yml
test:
strategy:
matrix:
test-type: [unit, integration, workflow]
steps:
- name: Run ${{ matrix.test-type }} tests
run: |
pytest -m ${{ matrix.test-type }} \
--cov --cov-report=xml \
--junit-xml=results-${{ matrix.test-type }}.xml
```
### 2. Quality Gates
```yaml
quality_gates:
- coverage: 90%
- type_checking: strict
- linting: no_errors
- security: no_vulnerabilities
- performance: no_regression
```

View File

@@ -1,386 +0,0 @@
# Directory Structure
## Overview
The test directory structure mirrors the source code for easy navigation and maintenance. Each source module has a corresponding test module with the same path structure.
## Complete Test Directory Structure
```
tests/
├── __init__.py
├── conftest.py # Root fixtures and configuration
├── pytest.ini # Pytest configuration
├── helpers/ # Shared test utilities
│ ├── __init__.py
│ ├── factories/ # Data factory modules
│ │ ├── __init__.py
│ │ ├── state_factories.py # State object factories
│ │ ├── message_factories.py # Message factories
│ │ ├── config_factories.py # Config factories
│ │ └── model_factories.py # Domain model factories
│ ├── fixtures/ # Reusable fixtures
│ │ ├── __init__.py
│ │ ├── service_fixtures.py # Service mocks
│ │ ├── graph_fixtures.py # Graph testing fixtures
│ │ └── async_fixtures.py # Async utilities
│ ├── mocks/ # Mock implementations
│ │ ├── __init__.py
│ │ ├── llm_mocks.py # LLM service mocks
│ │ ├── api_mocks.py # External API mocks
│ │ └── db_mocks.py # Database mocks
│ └── assertions/ # Custom assertions
│ ├── __init__.py
│ ├── state_assertions.py # State validation assertions
│ └── async_assertions.py # Async-specific assertions
├── unit/ # Unit tests (mirrors src structure)
│ ├── conftest.py # Unit test fixtures
│ ├── biz_bud/ # Tests for src/biz_bud
│ │ ├── __init__.py
│ │ ├── test_constants.py
│ │ ├── config/
│ │ │ ├── __init__.py
│ │ │ ├── test_loader.py
│ │ │ ├── test_models.py
│ │ │ └── test_constants.py
│ │ ├── graphs/
│ │ │ ├── __init__.py
│ │ │ ├── test_graph.py
│ │ │ ├── test_research.py
│ │ │ └── test_menu_intelligence.py
│ │ ├── nodes/
│ │ │ ├── __init__.py
│ │ │ ├── core/
│ │ │ │ ├── test_input.py
│ │ │ │ ├── test_output.py
│ │ │ │ └── test_error.py
│ │ │ ├── llm/
│ │ │ │ └── test_call.py
│ │ │ ├── research/
│ │ │ │ ├── test_search.py
│ │ │ │ ├── test_extract.py
│ │ │ │ └── test_synthesize.py
│ │ │ └── validation/
│ │ │ ├── test_content.py
│ │ │ └── test_logic.py
│ │ ├── services/
│ │ │ ├── __init__.py
│ │ │ ├── test_factory.py
│ │ │ ├── llm/
│ │ │ │ ├── test_client.py
│ │ │ │ └── test_exceptions.py
│ │ │ ├── test_db.py
│ │ │ └── test_vector_store.py
│ │ ├── states/
│ │ │ ├── __init__.py
│ │ │ ├── test_base.py
│ │ │ └── test_unified.py
│ │ └── utils/
│ │ ├── __init__.py
│ │ └── test_helpers.py
│ └── packages/ # Tests for packages/
│ ├── business_buddy_utils/
│ │ ├── __init__.py
│ │ ├── core/
│ │ │ ├── test_log_config.py
│ │ │ ├── test_error_handling.py
│ │ │ └── test_embeddings.py
│ │ ├── cache/
│ │ │ └── test_cache_manager.py
│ │ └── validation/
│ │ └── test_content_validation.py
│ └── business_buddy_tools/
│ ├── __init__.py
│ ├── scrapers/
│ │ └── test_unified_scraper.py
│ └── search/
│ └── test_web_search.py
├── integration/ # Integration tests
│ ├── conftest.py # Integration fixtures
│ ├── services/ # Service integration tests
│ │ ├── test_llm_with_cache.py
│ │ └── test_search_with_scraping.py
│ ├── nodes/ # Node integration tests
│ │ ├── test_research_pipeline.py
│ │ └── test_validation_flow.py
│ └── cross_package/ # Cross-package integration
│ ├── test_utils_tools_integration.py
│ └── test_config_services_integration.py
└── workflow/ # End-to-end workflow tests
├── conftest.py # Workflow fixtures
├── graphs/ # Complete graph tests
│ ├── test_research_graph_e2e.py
│ ├── test_analysis_graph_e2e.py
│ └── test_menu_intelligence_e2e.py
└── scenarios/ # Business scenario tests
├── test_competitor_analysis.py
├── test_market_research.py
└── test_menu_optimization.py
```
## Directory Conventions
### 1. Naming Conventions
```python
# Source file: src/biz_bud/services/llm/client.py
# Test file: tests/unit/biz_bud/services/llm/test_client.py
# Source file: packages/business-buddy-utils/src/bb_utils/cache/manager.py
# Test file: tests/unit/packages/business_buddy_utils/cache/test_manager.py
```
### 2. File Organization Rules
```python
# Each test file corresponds to one source file
# test_<source_filename>.py
# Example test file structure:
"""Test module for biz_bud.services.llm.client."""
import pytest
from unittest.mock import Mock, AsyncMock
from typing import List
from biz_bud.services.llm.client import LangchainLLMClient
from biz_bud.config.models import LLMConfig
from tests.helpers.factories import ConfigFactory, MessageFactory
class TestLangchainLLMClient:
"""Tests for LangchainLLMClient."""
@pytest.fixture
def config(self) -> LLMConfig:
"""Create test LLM configuration."""
return ConfigFactory.create_llm_config()
@pytest.fixture
def client(self, config: LLMConfig) -> LangchainLLMClient:
"""Create LLM client instance."""
return LangchainLLMClient(config)
async def test_initialization(self, client: LangchainLLMClient) -> None:
"""Test client initializes with correct configuration."""
assert client.config is not None
assert client.model_name == "gpt-4"
```
### 3. Conftest.py Hierarchy
```python
# tests/conftest.py - Root level fixtures
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
# tests/unit/conftest.py - Unit test specific
@pytest.fixture(autouse=True)
def mock_external_services(monkeypatch):
"""Automatically mock all external services for unit tests."""
monkeypatch.setenv("TESTING", "true")
# Mock external calls
# tests/integration/conftest.py - Integration specific
@pytest.fixture
async def real_cache():
"""Provide real cache instance for integration tests."""
cache = await create_test_cache()
yield cache
await cache.clear()
# tests/workflow/conftest.py - Workflow specific
@pytest.fixture
async def test_graph():
"""Create configured test graph."""
graph = create_research_graph()
yield graph
```
## Helper Module Structure
### 1. Factories Module
```python
# tests/helpers/factories/state_factories.py
from typing import Dict, Any, List
from biz_bud.states import ResearchState, BaseState
class StateFactory:
"""Factory for creating test state objects."""
@staticmethod
def create_base_state(**overrides) -> BaseState:
"""Create a base state with defaults."""
defaults = {
"messages": [],
"errors": [],
"config": {},
"status": "pending",
"thread_id": f"test-{uuid.uuid4()}",
}
return BaseState(**{**defaults, **overrides})
@staticmethod
def create_research_state(**overrides) -> ResearchState:
"""Create a research state with defaults."""
base = StateFactory.create_base_state()
research_defaults = {
"query": "test query",
"search_queries": [],
"search_results": [],
"extracted_info": {},
"synthesis": "",
}
return ResearchState(**{**base, **research_defaults, **overrides})
```
### 2. Mock Module
```python
# tests/helpers/mocks/llm_mocks.py
from unittest.mock import AsyncMock, Mock
from typing import List, Dict, Any
class LLMMockFactory:
"""Factory for creating LLM mocks."""
@staticmethod
def create_success_mock(response: str = "Success") -> AsyncMock:
"""Create mock that returns successful response."""
mock = AsyncMock()
mock.complete.return_value = response
mock.chat.return_value = response
return mock
@staticmethod
def create_rate_limit_mock() -> AsyncMock:
"""Create mock that simulates rate limiting."""
mock = AsyncMock()
mock.complete.side_effect = [
RateLimitError("Rate limited"),
"Success after retry"
]
return mock
```
### 3. Assertion Module
```python
# tests/helpers/assertions/state_assertions.py
from typing import TypedDict, Any
import pytest
def assert_state_valid(state: TypedDict) -> None:
"""Assert that state contains all required fields."""
required_fields = ["messages", "errors", "config", "status"]
for field in required_fields:
assert field in state, f"State missing required field: {field}"
def assert_no_errors(state: TypedDict) -> None:
"""Assert that state contains no errors."""
assert "errors" in state
assert len(state["errors"]) == 0, f"State contains errors: {state['errors']}"
```
## Import Structure
### 1. Standard Import Order
```python
# Standard library imports
import asyncio
import json
from typing import Dict, List, Any
from unittest.mock import Mock, AsyncMock, patch
# Third-party imports
import pytest
from pydantic import BaseModel
# Local application imports
from biz_bud.services import ServiceFactory
from biz_bud.states import ResearchState
# Test helper imports
from tests.helpers.factories import StateFactory
from tests.helpers.mocks import LLMMockFactory
from tests.helpers.assertions import assert_state_valid
```
### 2. Relative vs Absolute Imports
```python
# Always use absolute imports in tests
# ✅ GOOD
from biz_bud.services.llm.client import LangchainLLMClient
# ❌ BAD
from ...services.llm.client import LangchainLLMClient
```
## Configuration Files
### 1. pytest.ini
```ini
# tests/pytest.ini
[pytest]
minversion = 8.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Markers
markers =
unit: Unit tests
integration: Integration tests
workflow: Workflow tests
slow: Slow tests (>5s)
external_api: Tests requiring external API
# Asyncio
asyncio_mode = auto
# Coverage
addopts =
--strict-markers
--strict-config
--verbose
--cov=src
--cov=packages
--cov-branch
--cov-report=term-missing:skip-covered
--cov-report=html
--cov-report=xml
```
### 2. .coveragerc
```ini
# tests/.coveragerc
[run]
source = src,packages
branch = True
parallel = True
omit =
*/tests/*
*/test_*
*/__init__.py
*/conftest.py
[report]
precision = 2
show_missing = True
skip_covered = False
fail_under = 90
[html]
directory = coverage_html
title = BizBud Coverage Report
```

View File

@@ -1,542 +0,0 @@
# Fixture Architecture
## Overview
Fixtures are the backbone of our testing infrastructure, providing reusable setup and teardown logic. They follow a hierarchical structure with clear ownership and lifecycle management.
## Fixture Design Principles
### 1. Scope Management
```python
# Session scope - expensive resources shared across all tests
@pytest.fixture(scope="session")
def database_schema() -> None:
"""Create test database schema once per session."""
create_test_schema()
yield
drop_test_schema()
# Module scope - shared within a test module
@pytest.fixture(scope="module")
def trained_model() -> Model:
"""Load ML model once per module."""
return load_pretrained_model()
# Function scope (default) - fresh for each test
@pytest.fixture
def clean_state() -> State:
"""Provide fresh state for each test."""
return create_empty_state()
```
### 2. Dependency Injection
```python
# Fixtures can depend on other fixtures
@pytest.fixture
def app_config() -> AppConfig:
"""Base application configuration."""
return AppConfig.model_validate({})
@pytest.fixture
def llm_config(app_config: AppConfig) -> LLMConfig:
"""LLM configuration derived from app config."""
return app_config.llm
@pytest.fixture
def llm_client(llm_config: LLMConfig) -> LangchainLLMClient:
"""LLM client with injected configuration."""
return LangchainLLMClient(llm_config)
```
### 3. Parametrization
```python
# Fixture parametrization for multiple scenarios
@pytest.fixture(params=["gpt-4", "claude-3", "llama-3"])
def model_name(request) -> str:
"""Provide different model names."""
return request.param
@pytest.fixture
def llm_client_multi(model_name: str, app_config: AppConfig) -> LangchainLLMClient:
"""LLM client for multiple models."""
config = app_config.model_copy()
config.llm.model = model_name
return LangchainLLMClient(config.llm)
```
## Core Fixture Categories
### 1. Configuration Fixtures
```python
# tests/conftest.py
from typing import Dict, Any
import pytest
from biz_bud.config.models import AppConfig, APIKeys, LLMProfileConfig
@pytest.fixture(scope="session")
def test_api_keys() -> APIKeys:
"""Test API keys with fake values."""
return APIKeys(
openai_api_key="test-openai-key",
anthropic_api_key="test-anthropic-key",
google_api_key="test-google-key",
tavily_api_key="test-tavily-key",
jina_api_key="test-jina-key",
)
@pytest.fixture(scope="session")
def base_app_config(test_api_keys: APIKeys) -> AppConfig:
"""Base configuration for all tests."""
return AppConfig(
api_keys=test_api_keys,
llm_profiles={
"small": LLMProfileConfig(
model="gpt-4o-mini",
temperature=0.0,
max_tokens=1000,
),
"large": LLMProfileConfig(
model="gpt-4o",
temperature=0.7,
max_tokens=4000,
),
},
log_level="DEBUG",
redis_url="redis://localhost:6379/15", # Test DB
)
@pytest.fixture
def app_config(base_app_config: AppConfig) -> AppConfig:
"""Function-scoped config that can be modified."""
return base_app_config.model_copy(deep=True)
```
### 2. Service Mock Fixtures
```python
# tests/helpers/fixtures/service_fixtures.py
from unittest.mock import AsyncMock, Mock, create_autospec
from typing import TypeVar, Type, Protocol
import pytest
T = TypeVar('T')
class ServiceMockFactory:
"""Factory for creating properly typed service mocks."""
@staticmethod
def create_mock[T](
service_class: Type[T],
config: AppConfig,
async_methods: bool = True
) -> Mock:
"""Create a mock with proper spec and async support."""
mock = create_autospec(service_class, spec_set=True)
# Add config
mock.config = config
# Convert async methods
if async_methods:
for attr_name in dir(service_class):
attr = getattr(service_class, attr_name, None)
if asyncio.iscoroutinefunction(attr):
setattr(mock, attr_name, AsyncMock(spec=attr))
return mock
@pytest.fixture
def mock_llm_client(app_config: AppConfig) -> Mock:
"""Mock LLM client with common responses."""
mock = ServiceMockFactory.create_mock(LangchainLLMClient, app_config)
# Configure default responses
mock.complete.return_value = "Default LLM response"
mock.chat.return_value = AIMessage(content="Chat response")
mock.json_mode.return_value = {"key": "value"}
return mock
@pytest.fixture
def mock_vector_store(app_config: AppConfig) -> Mock:
"""Mock vector store with search capabilities."""
mock = ServiceMockFactory.create_mock(VectorStore, app_config)
# Configure default behaviors
mock.upsert.return_value = ["vec-1", "vec-2"]
mock.search.return_value = []
mock.delete.return_value = True
return mock
@pytest.fixture
def mock_web_search(app_config: AppConfig) -> Mock:
"""Mock web search tool."""
mock = ServiceMockFactory.create_mock(WebSearchTool, app_config)
# Default search results
mock.search.return_value = [
SearchResult(
url="https://example.com",
title="Example Result",
snippet="This is an example",
relevance_score=0.9,
)
]
return mock
```
### 3. State Factory Fixtures
```python
# tests/helpers/fixtures/state_fixtures.py
from typing import Dict, Any, List, Optional
from uuid import uuid4
import pytest
from biz_bud.states import BaseState, ResearchState, AnalysisState
from langchain_core.messages import HumanMessage, AIMessage
class StateBuilder:
"""Fluent builder for test states."""
def __init__(self, state_class: Type[TypedDict]):
self.state_class = state_class
self.data = self._get_defaults()
def _get_defaults(self) -> Dict[str, Any]:
"""Get default values for state."""
return {
"messages": [],
"errors": [],
"config": {},
"status": "pending",
"thread_id": f"test-{uuid4()}",
"context": {},
"run_metadata": {},
}
def with_messages(self, *messages: BaseMessage) -> "StateBuilder":
"""Add messages to state."""
self.data["messages"].extend(messages)
return self
def with_human_message(self, content: str) -> "StateBuilder":
"""Add human message."""
return self.with_messages(HumanMessage(content=content))
def with_ai_message(self, content: str) -> "StateBuilder":
"""Add AI message."""
return self.with_messages(AIMessage(content=content))
def with_error(
self,
error_type: str,
message: str,
details: Optional[Dict] = None
) -> "StateBuilder":
"""Add error to state."""
self.data["errors"].append({
"error_type": error_type,
"message": message,
"details": details or {},
"timestamp": datetime.utcnow().isoformat(),
})
return self
def with_config(self, **config_kwargs) -> "StateBuilder":
"""Update config values."""
self.data["config"].update(config_kwargs)
return self
def with_status(self, status: str) -> "StateBuilder":
"""Set status."""
self.data["status"] = status
return self
def build(self) -> TypedDict:
"""Build the state object."""
return self.state_class(**self.data)
@pytest.fixture
def state_builder() -> Type[StateBuilder]:
"""Provide state builder class."""
return StateBuilder
@pytest.fixture
def base_state() -> BaseState:
"""Create base state with defaults."""
return StateBuilder(BaseState).build()
@pytest.fixture
def research_state() -> ResearchState:
"""Create research state with defaults."""
builder = StateBuilder(ResearchState)
builder.data.update({
"query": "test research query",
"search_queries": [],
"search_results": [],
"extracted_info": {},
"synthesis": "",
})
return builder.build()
```
### 4. Async Fixtures
```python
# tests/helpers/fixtures/async_fixtures.py
import asyncio
from typing import AsyncGenerator
import pytest
@pytest.fixture
async def async_client_session() -> AsyncGenerator[aiohttp.ClientSession, None]:
"""Provide async HTTP client session."""
async with aiohttp.ClientSession() as session:
yield session
@pytest.fixture
async def initialized_service(
app_config: AppConfig
) -> AsyncGenerator[MyAsyncService, None]:
"""Provide initialized async service."""
service = MyAsyncService(app_config)
await service.initialize()
yield service
await service.cleanup()
@pytest.fixture
async def redis_client(
app_config: AppConfig
) -> AsyncGenerator[Redis, None]:
"""Provide Redis client for tests."""
client = await create_redis_pool(
app_config.redis_url,
db=15, # Test database
)
yield client
await client.flushdb() # Clean test data
client.close()
await client.wait_closed()
```
### 5. Graph Testing Fixtures
```python
# tests/helpers/fixtures/graph_fixtures.py
from typing import Dict, Any, Optional
from langgraph.graph import StateGraph
import pytest
@pytest.fixture
def mock_graph_config(
mock_service_factory: Mock,
app_config: AppConfig
) -> RunnableConfig:
"""Create config for graph execution."""
return {
"configurable": {
"thread_id": f"test-{uuid4()}",
"service_factory": mock_service_factory,
"app_config": app_config,
},
"callbacks": [],
"tags": ["test"],
"metadata": {"test_run": True},
}
@pytest.fixture
def graph_runner(mock_graph_config: RunnableConfig):
"""Helper for running graphs in tests."""
class GraphRunner:
def __init__(self, config: RunnableConfig):
self.config = config
async def run(
self,
graph: StateGraph,
initial_state: Dict[str, Any],
expected_status: str = "success"
) -> Dict[str, Any]:
"""Run graph and verify status."""
result = await graph.ainvoke(initial_state, self.config)
assert result["status"] == expected_status
assert len(result.get("errors", [])) == 0
return result
async def run_with_error(
self,
graph: StateGraph,
initial_state: Dict[str, Any],
expected_error_type: str
) -> Dict[str, Any]:
"""Run graph expecting error."""
result = await graph.ainvoke(initial_state, self.config)
assert result["status"] == "error"
assert len(result["errors"]) > 0
assert any(
e["error_type"] == expected_error_type
for e in result["errors"]
)
return result
return GraphRunner(mock_graph_config)
```
## Advanced Fixture Patterns
### 1. Fixture Factories
```python
# Create fixtures dynamically
def create_service_fixture(
service_class: Type[T],
default_config: Optional[Dict] = None
) -> pytest.fixture:
"""Create a fixture for a service class."""
@pytest.fixture
def service_fixture(app_config: AppConfig) -> T:
config = app_config.model_copy()
if default_config:
config = config.model_validate({**config.model_dump(), **default_config})
return service_class(config)
return service_fixture
# Usage
llm_service = create_service_fixture(LLMService, {"model": "gpt-4"})
```
### 2. Conditional Fixtures
```python
# Skip tests based on conditions
@pytest.fixture
def requires_redis(redis_client: Redis) -> Redis:
"""Skip test if Redis not available."""
try:
redis_client.ping()
return redis_client
except ConnectionError:
pytest.skip("Redis not available")
@pytest.fixture
def requires_gpu() -> None:
"""Skip test if GPU not available."""
if not torch.cuda.is_available():
pytest.skip("GPU required")
```
### 3. Fixture Composition
```python
# Compose complex fixtures from simpler ones
@pytest.fixture
def complete_test_environment(
app_config: AppConfig,
mock_llm_client: Mock,
mock_vector_store: Mock,
mock_web_search: Mock,
state_builder: Type[StateBuilder],
) -> Dict[str, Any]:
"""Complete test environment with all dependencies."""
return {
"config": app_config,
"services": {
"llm": mock_llm_client,
"vector_store": mock_vector_store,
"search": mock_web_search,
},
"state_builder": state_builder,
"test_data": load_test_data(),
}
```
### 4. Yield Fixtures for Cleanup
```python
@pytest.fixture
async def temp_directory() -> AsyncGenerator[Path, None]:
"""Create temporary directory for test."""
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
# Cleanup
shutil.rmtree(temp_dir)
@pytest.fixture
async def database_transaction(
db_connection: Connection
) -> AsyncGenerator[Transaction, None]:
"""Run test in transaction that rolls back."""
tx = await db_connection.begin()
yield tx
await tx.rollback()
```
## Fixture Best Practices
### 1. Naming Conventions
```python
# Descriptive names indicating what they provide
@pytest.fixture
def empty_research_state() -> ResearchState
@pytest.fixture
def populated_research_state() -> ResearchState
@pytest.fixture
def failed_research_state() -> ResearchState
# Not just "state" or "data"
```
### 2. Documentation
```python
@pytest.fixture
def complex_fixture() -> ComplexType:
"""
Provide a fully configured ComplexType instance.
This fixture:
- Initializes with test configuration
- Mocks external dependencies
- Sets up test data in database
- Returns ready-to-use instance
Cleanup:
- Removes test data
- Closes connections
"""
# Implementation
```
### 3. Error Handling
```python
@pytest.fixture
def resilient_fixture():
"""Fixture with proper error handling."""
resource = None
try:
resource = acquire_resource()
yield resource
except Exception as e:
pytest.fail(f"Fixture setup failed: {e}")
finally:
if resource:
release_resource(resource)
```

View File

@@ -1,675 +0,0 @@
# Mocking Strategies
## Overview
Effective mocking is crucial for creating fast, reliable, and maintainable tests. This guide covers strategies for mocking different types of components in the BizBud architecture.
## Core Mocking Principles
### 1. Mock at Service Boundaries
```python
# ✅ GOOD: Mock external service
@patch("aiohttp.ClientSession.get")
async def test_api_call(mock_get: AsyncMock) -> None:
mock_get.return_value.__aenter__.return_value.json = AsyncMock(
return_value={"status": "success"}
)
result = await fetch_external_data()
assert result["status"] == "success"
# ❌ BAD: Mock internal implementation
@patch("my_module._internal_helper")
def test_something(mock_helper: Mock) -> None:
# Don't mock internal implementation details
pass
```
### 2. Use Proper Specs
```python
from unittest.mock import create_autospec
# ✅ GOOD: Mock with spec ensures interface compliance
mock_service = create_autospec(LLMService, spec_set=True)
# ❌ BAD: Generic mock allows any attribute access
mock_service = Mock()
```
### 3. Async Mock Support
```python
# Proper async mock creation
def create_async_mock(spec_class: Type[T]) -> Mock:
"""Create mock with async method support."""
mock = create_autospec(spec_class, spec_set=True)
# Convert coroutine methods to AsyncMock
for name in dir(spec_class):
attr = getattr(spec_class, name, None)
if asyncio.iscoroutinefunction(attr):
setattr(mock, name, AsyncMock(spec=attr))
return mock
```
## Component-Specific Mocking Strategies
### 1. LLM Service Mocking
```python
# tests/helpers/mocks/llm_mocks.py
from typing import List, Dict, Any, Optional
from unittest.mock import AsyncMock, Mock
from langchain_core.messages import AIMessage, BaseMessage
class LLMMockBuilder:
"""Builder for creating configured LLM mocks."""
def __init__(self):
self.mock = create_autospec(LangchainLLMClient, spec_set=True)
self._configure_defaults()
def _configure_defaults(self) -> None:
"""Set default behaviors."""
self.mock.complete = AsyncMock(return_value="Default response")
self.mock.chat = AsyncMock(
return_value=AIMessage(content="Default chat response")
)
self.mock.json_mode = AsyncMock(return_value={"default": "json"})
def with_response(self, response: str) -> "LLMMockBuilder":
"""Configure specific response."""
self.mock.complete.return_value = response
self.mock.chat.return_value = AIMessage(content=response)
return self
def with_streaming(self, chunks: List[str]) -> "LLMMockBuilder":
"""Configure streaming response."""
async def stream_response():
for chunk in chunks:
yield chunk
self.mock.stream = AsyncMock(side_effect=stream_response)
return self
def with_error(
self,
error_class: Type[Exception],
message: str = "Error"
) -> "LLMMockBuilder":
"""Configure error response."""
self.mock.complete.side_effect = error_class(message)
self.mock.chat.side_effect = error_class(message)
return self
def with_retry_behavior(
self,
failures: int,
eventual_response: str
) -> "LLMMockBuilder":
"""Configure retry behavior."""
responses = [RateLimitError("Rate limited")] * failures
responses.append(eventual_response)
self.mock.complete.side_effect = responses
return self
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
# Usage example
@pytest.fixture
def mock_llm_with_retry() -> Mock:
"""LLM mock that fails twice then succeeds."""
return (
LLMMockBuilder()
.with_retry_behavior(failures=2, eventual_response="Success")
.build()
)
```
### 2. Database Mocking
```python
# tests/helpers/mocks/db_mocks.py
from typing import Dict, List, Any, Optional
from datetime import datetime
class DatabaseMockBuilder:
"""Builder for database mocks with realistic behavior."""
def __init__(self):
self.mock = create_autospec(PostgresStore, spec_set=True)
self._storage: Dict[str, Any] = {}
self._configure_defaults()
def _configure_defaults(self) -> None:
"""Configure default CRUD operations."""
self.mock.store = AsyncMock(side_effect=self._store)
self.mock.retrieve = AsyncMock(side_effect=self._retrieve)
self.mock.update = AsyncMock(side_effect=self._update)
self.mock.delete = AsyncMock(side_effect=self._delete)
self.mock.query = AsyncMock(side_effect=self._query)
async def _store(self, key: str, value: Any) -> str:
"""Simulate storing data."""
self._storage[key] = {
"value": value,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
return key
async def _retrieve(self, key: str) -> Optional[Any]:
"""Simulate retrieving data."""
if key in self._storage:
return self._storage[key]["value"]
return None
async def _update(self, key: str, value: Any) -> bool:
"""Simulate updating data."""
if key in self._storage:
self._storage[key]["value"] = value
self._storage[key]["updated_at"] = datetime.utcnow()
return True
return False
async def _delete(self, key: str) -> bool:
"""Simulate deleting data."""
if key in self._storage:
del self._storage[key]
return True
return False
async def _query(self, filters: Dict[str, Any]) -> List[Any]:
"""Simulate querying data."""
results = []
for key, data in self._storage.items():
if all(
data["value"].get(k) == v
for k, v in filters.items()
):
results.append(data["value"])
return results
def with_data(self, initial_data: Dict[str, Any]) -> "DatabaseMockBuilder":
"""Pre-populate with test data."""
for key, value in initial_data.items():
self._storage[key] = {
"value": value,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
return self
def with_error_on_key(
self,
key: str,
error: Exception
) -> "DatabaseMockBuilder":
"""Configure error for specific key."""
original_retrieve = self.mock.retrieve.side_effect
async def retrieve_with_error(k: str):
if k == key:
raise error
return await original_retrieve(k)
self.mock.retrieve.side_effect = retrieve_with_error
return self
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
```
### 3. Web Service Mocking
```python
# tests/helpers/mocks/web_mocks.py
from typing import List, Dict, Optional
import json
class WebSearchMockBuilder:
"""Builder for web search mocks."""
def __init__(self):
self.mock = create_autospec(WebSearchTool, spec_set=True)
self._configure_defaults()
def _configure_defaults(self) -> None:
"""Set default search behavior."""
self.mock.search = AsyncMock(return_value=[])
self.mock.search_with_options = AsyncMock(return_value=[])
def with_results(
self,
results: List[SearchResult]
) -> "WebSearchMockBuilder":
"""Configure search results."""
self.mock.search.return_value = results
self.mock.search_with_options.return_value = results
return self
def with_query_mapping(
self,
query_results: Dict[str, List[SearchResult]]
) -> "WebSearchMockBuilder":
"""Map queries to specific results."""
async def search_by_query(query: str, **kwargs):
for pattern, results in query_results.items():
if pattern.lower() in query.lower():
return results
return []
self.mock.search.side_effect = search_by_query
return self
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
class ScraperMockBuilder:
"""Builder for web scraper mocks."""
def __init__(self):
self.mock = create_autospec(UnifiedScraper, spec_set=True)
self._url_content: Dict[str, str] = {}
self._configure_defaults()
def _configure_defaults(self) -> None:
"""Configure default scraping behavior."""
self.mock.scrape = AsyncMock(side_effect=self._scrape)
self.mock.batch_scrape = AsyncMock(side_effect=self._batch_scrape)
async def _scrape(self, url: str, **kwargs) -> ScrapedContent:
"""Simulate scraping a URL."""
if url in self._url_content:
return ScrapedContent(
content=self._url_content[url],
metadata={"source_url": url}
)
raise ScrapingError(f"Failed to scrape {url}")
async def _batch_scrape(
self,
urls: List[str],
**kwargs
) -> List[ScrapedContent]:
"""Simulate batch scraping."""
results = []
for url in urls:
try:
content = await self._scrape(url)
results.append(content)
except ScrapingError:
results.append(None)
return results
def with_url_content(
self,
url_content_map: Dict[str, str]
) -> "ScraperMockBuilder":
"""Map URLs to content."""
self._url_content.update(url_content_map)
return self
def with_failure_rate(
self,
rate: float
) -> "ScraperMockBuilder":
"""Configure random failures."""
import random
original_scrape = self.mock.scrape.side_effect
async def scrape_with_failures(url: str, **kwargs):
if random.random() < rate:
raise ScrapingError("Random failure")
return await original_scrape(url, **kwargs)
self.mock.scrape.side_effect = scrape_with_failures
return self
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
```
### 4. Cache Mocking
```python
# tests/helpers/mocks/cache_mocks.py
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
class CacheMockBuilder:
"""Builder for cache mocks with TTL support."""
def __init__(self):
self.mock = create_autospec(RedisCacheBackend, spec_set=True)
self._cache: Dict[str, Dict[str, Any]] = {}
self._configure_defaults()
def _configure_defaults(self) -> None:
"""Configure default cache operations."""
self.mock.get = AsyncMock(side_effect=self._get)
self.mock.set = AsyncMock(side_effect=self._set)
self.mock.delete = AsyncMock(side_effect=self._delete)
self.mock.exists = AsyncMock(side_effect=self._exists)
self.mock.clear = AsyncMock(side_effect=self._clear)
async def _get(self, key: str) -> Optional[Any]:
"""Get value with TTL check."""
if key in self._cache:
entry = self._cache[key]
if entry["expires_at"] > datetime.utcnow():
return entry["value"]
else:
del self._cache[key]
return None
async def _set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> bool:
"""Set value with optional TTL."""
expires_at = datetime.max
if ttl:
expires_at = datetime.utcnow() + timedelta(seconds=ttl)
self._cache[key] = {
"value": value,
"expires_at": expires_at,
}
return True
async def _delete(self, key: str) -> bool:
"""Delete key from cache."""
if key in self._cache:
del self._cache[key]
return True
return False
async def _exists(self, key: str) -> bool:
"""Check if key exists."""
return key in self._cache
async def _clear(self) -> None:
"""Clear all cache."""
self._cache.clear()
def with_cached_data(
self,
data: Dict[str, Any]
) -> "CacheMockBuilder":
"""Pre-populate cache."""
for key, value in data.items():
self._cache[key] = {
"value": value,
"expires_at": datetime.max,
}
return self
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
```
## Advanced Mocking Patterns
### 1. Contextual Mocking
```python
class ContextualMock:
"""Mock that changes behavior based on context."""
def __init__(self, spec_class: Type):
self.mock = create_autospec(spec_class, spec_set=True)
self._contexts: Dict[str, Dict[str, Any]] = {}
self._current_context = "default"
def add_context(
self,
name: str,
behaviors: Dict[str, Any]
) -> "ContextualMock":
"""Add named context with behaviors."""
self._contexts[name] = behaviors
return self
def switch_context(self, name: str) -> None:
"""Switch to named context."""
if name not in self._contexts:
raise ValueError(f"Unknown context: {name}")
self._current_context = name
behaviors = self._contexts[name]
for method_name, behavior in behaviors.items():
setattr(self.mock, method_name, behavior)
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
# Usage
mock = ContextualMock(LLMService)
mock.add_context("success", {
"complete": AsyncMock(return_value="Success"),
})
mock.add_context("failure", {
"complete": AsyncMock(side_effect=Exception("Failed")),
})
```
### 2. Spy Pattern
```python
class SpyMock:
"""Mock that wraps real implementation."""
def __init__(self, real_instance: Any):
self.real = real_instance
self.mock = Mock(wraps=real_instance)
self.call_history: List[Dict[str, Any]] = []
self._wrap_methods()
def _wrap_methods(self) -> None:
"""Wrap all methods to track calls."""
for name in dir(self.real):
if name.startswith("_"):
continue
attr = getattr(self.real, name)
if callable(attr):
wrapped = self._create_wrapper(name, attr)
setattr(self.mock, name, wrapped)
def _create_wrapper(self, name: str, method: Callable):
"""Create wrapper that tracks calls."""
if asyncio.iscoroutinefunction(method):
async def async_wrapper(*args, **kwargs):
self.call_history.append({
"method": name,
"args": args,
"kwargs": kwargs,
"timestamp": datetime.utcnow(),
})
return await method(*args, **kwargs)
return async_wrapper
else:
def sync_wrapper(*args, **kwargs):
self.call_history.append({
"method": name,
"args": args,
"kwargs": kwargs,
"timestamp": datetime.utcnow(),
})
return method(*args, **kwargs)
return sync_wrapper
def get_calls(self, method_name: str) -> List[Dict[str, Any]]:
"""Get calls for specific method."""
return [
call for call in self.call_history
if call["method"] == method_name
]
```
### 3. State Machine Mocking
```python
class StateMachineMock:
"""Mock with state-dependent behavior."""
def __init__(self, spec_class: Type):
self.mock = create_autospec(spec_class, spec_set=True)
self._states: Dict[str, Dict[str, Any]] = {}
self._transitions: Dict[str, List[Tuple[str, str]]] = {}
self._current_state = "initial"
def add_state(
self,
name: str,
behaviors: Dict[str, Any]
) -> "StateMachineMock":
"""Add state with behaviors."""
self._states[name] = behaviors
return self
def add_transition(
self,
from_state: str,
to_state: str,
trigger: str
) -> "StateMachineMock":
"""Add state transition."""
if trigger not in self._transitions:
self._transitions[trigger] = []
self._transitions[trigger].append((from_state, to_state))
return self
def _apply_state(self) -> None:
"""Apply current state behaviors."""
if self._current_state in self._states:
behaviors = self._states[self._current_state]
for method_name, behavior in behaviors.items():
setattr(self.mock, method_name, behavior)
def trigger(self, event: str) -> None:
"""Trigger state transition."""
if event in self._transitions:
for from_state, to_state in self._transitions[event]:
if self._current_state == from_state:
self._current_state = to_state
self._apply_state()
break
```
## Testing with Mocks
### 1. Mock Assertion Patterns
```python
# Verify specific call arguments
mock_service.process.assert_called_with(
data=expected_data,
options={"retry": True}
)
# Verify call order
mock_service.assert_has_calls([
call.initialize(),
call.process(data),
call.cleanup()
], any_order=False)
# Verify partial arguments
mock_service.process.assert_called()
args, kwargs = mock_service.process.call_args
assert "important_field" in kwargs
assert kwargs["important_field"] == expected_value
```
### 2. Mock Configuration Testing
```python
@pytest.mark.parametrize(
"config,expected_behavior",
[
({"mode": "fast"}, "quick_response"),
({"mode": "accurate"}, "detailed_response"),
({"mode": "balanced"}, "balanced_response"),
]
)
async def test_config_affects_behavior(
config: Dict[str, str],
expected_behavior: str,
mock_factory: Callable
) -> None:
mock = mock_factory(config)
result = await mock.process()
assert result == expected_behavior
```
## Mock Management Best Practices
### 1. Centralized Mock Registry
```python
# tests/helpers/mocks/registry.py
class MockRegistry:
"""Central registry for all mocks."""
_builders = {
"llm": LLMMockBuilder,
"database": DatabaseMockBuilder,
"search": WebSearchMockBuilder,
"scraper": ScraperMockBuilder,
"cache": CacheMockBuilder,
}
@classmethod
def create(cls, service_type: str, **config) -> Mock:
"""Create mock by type."""
if service_type not in cls._builders:
raise ValueError(f"Unknown service type: {service_type}")
builder = cls._builders[service_type]()
for key, value in config.items():
method = getattr(builder, f"with_{key}", None)
if method:
method(value)
return builder.build()
```
### 2. Mock Lifecycle Management
```python
@pytest.fixture
async def managed_mocks() -> AsyncGenerator[Dict[str, Mock], None]:
"""Provide mocks with lifecycle management."""
mocks = {
"llm": MockRegistry.create("llm"),
"db": MockRegistry.create("database"),
"cache": MockRegistry.create("cache"),
}
# Setup
for mock in mocks.values():
if hasattr(mock, "initialize"):
await mock.initialize()
yield mocks
# Teardown
for mock in mocks.values():
if hasattr(mock, "cleanup"):
await mock.cleanup()
```

View File

@@ -1,732 +0,0 @@
# Testing Patterns
## Overview
This guide provides specific testing patterns for different component types in the BizBud architecture. Each pattern is designed to ensure comprehensive coverage while maintaining test clarity and maintainability.
## Node Testing Patterns
### 1. Basic Node Testing
```python
# tests/unit/biz_bud/nodes/research/test_search.py
import pytest
from typing import Dict, Any
from unittest.mock import Mock, AsyncMock
from biz_bud.nodes.research.search import search_node
from biz_bud.states import ResearchState
from tests.helpers.factories import StateFactory, SearchResultFactory
class TestSearchNode:
"""Tests for the search node."""
@pytest.fixture
def initial_state(self) -> ResearchState:
"""Create initial state for search node."""
return StateFactory.create_research_state(
query="What is quantum computing?",
search_queries=["quantum computing basics", "quantum computing applications"]
)
@pytest.fixture
def mock_search_tool(self) -> Mock:
"""Mock search tool with predefined results."""
mock = AsyncMock()
mock.search.return_value = [
SearchResultFactory.create(
title="Introduction to Quantum Computing",
url="https://example.com/quantum-intro",
snippet="Quantum computing uses quantum mechanics...",
),
SearchResultFactory.create(
title="Quantum Computing Applications",
url="https://example.com/quantum-apps",
snippet="Applications include cryptography...",
),
]
return mock
@pytest.mark.asyncio
async def test_successful_search(
self,
initial_state: ResearchState,
mock_search_tool: Mock,
mock_config: Dict[str, Any]
) -> None:
"""Test successful search execution."""
# Arrange
mock_config["search_tool"] = mock_search_tool
# Act
result = await search_node(initial_state, mock_config)
# Assert
assert "search_results" in result
assert len(result["search_results"]) == 2
assert result["search_results"][0]["title"] == "Introduction to Quantum Computing"
# Verify search tool was called correctly
assert mock_search_tool.search.call_count == 2
mock_search_tool.search.assert_any_call("quantum computing basics")
mock_search_tool.search.assert_any_call("quantum computing applications")
@pytest.mark.asyncio
async def test_search_with_empty_queries(
self,
mock_config: Dict[str, Any]
) -> None:
"""Test node behavior with no search queries."""
# Arrange
state = StateFactory.create_research_state(search_queries=[])
# Act
result = await search_node(state, mock_config)
# Assert
assert result["search_results"] == []
assert "errors" not in result or len(result["errors"]) == 0
@pytest.mark.asyncio
async def test_search_error_handling(
self,
initial_state: ResearchState,
mock_search_tool: Mock,
mock_config: Dict[str, Any]
) -> None:
"""Test error handling during search."""
# Arrange
mock_search_tool.search.side_effect = Exception("Search API error")
mock_config["search_tool"] = mock_search_tool
# Act
result = await search_node(initial_state, mock_config)
# Assert
assert "errors" in result
assert len(result["errors"]) > 0
assert result["errors"][0]["error_type"] == "search_error"
assert "Search API error" in result["errors"][0]["message"]
```
### 2. Node with State Transitions
```python
# Pattern for testing nodes that make decisions
class TestDecisionNode:
"""Test node that routes to different paths."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input_state,expected_next",
[
(
{"query_complexity": "simple", "results_count": 5},
"synthesize"
),
(
{"query_complexity": "complex", "results_count": 2},
"expand_search"
),
(
{"query_complexity": "simple", "results_count": 0},
"fallback_search"
),
],
ids=["simple_query", "complex_query", "no_results"]
)
async def test_routing_logic(
self,
input_state: Dict[str, Any],
expected_next: str,
state_builder: StateBuilder
) -> None:
"""Test routing decisions based on state."""
# Arrange
state = state_builder.with_config(**input_state).build()
# Act
result = await decision_node(state, {})
# Assert
assert result["next_step"] == expected_next
```
### 3. Node with Complex Dependencies
```python
# Pattern for nodes with multiple service dependencies
class TestComplexNode:
"""Test node with multiple dependencies."""
@pytest.fixture
def mock_services(self) -> Dict[str, Mock]:
"""Create all required service mocks."""
return {
"llm": LLMMockBuilder().with_response("Analysis complete").build(),
"db": DatabaseMockBuilder().with_data({"context": "previous_data"}).build(),
"cache": CacheMockBuilder().build(),
}
@pytest.mark.asyncio
async def test_dependency_orchestration(
self,
mock_services: Dict[str, Mock],
initial_state: State
) -> None:
"""Test that node correctly orchestrates dependencies."""
# Arrange
config = {"services": mock_services}
# Act
result = await complex_node(initial_state, config)
# Assert
# Verify service call order
mock_services["cache"].get.assert_called_once()
mock_services["db"].retrieve.assert_called_once()
mock_services["llm"].complete.assert_called_once()
# Verify data flow
llm_call_args = mock_services["llm"].complete.call_args[0][0]
assert "previous_data" in llm_call_args
```
## Service Testing Patterns
### 1. Service Initialization Testing
```python
# tests/unit/biz_bud/services/test_llm_client.py
class TestLLMServiceInitialization:
"""Test service initialization and configuration."""
@pytest.mark.parametrize(
"config_overrides,expected_model",
[
({"model": "gpt-4"}, "gpt-4"),
({"model": "claude-3"}, "claude-3"),
({}, "gpt-4o"), # default
]
)
def test_initialization_with_config(
self,
base_config: AppConfig,
config_overrides: Dict[str, Any],
expected_model: str
) -> None:
"""Test service initializes with correct configuration."""
# Arrange
config = base_config.model_copy()
for key, value in config_overrides.items():
setattr(config.llm, key, value)
# Act
service = LangchainLLMClient(config.llm)
# Assert
assert service.model == expected_model
assert service.config == config.llm
@pytest.mark.asyncio
async def test_async_initialization(self) -> None:
"""Test async initialization pattern."""
# Arrange
service = AsyncService()
# Act
await service.initialize()
# Assert
assert service.is_initialized
assert service.connection_pool is not None
```
### 2. Service Method Testing
```python
# Pattern for testing service methods with various inputs
class TestLLMServiceMethods:
"""Test LLM service methods."""
@pytest.fixture
async def initialized_service(
self,
mock_model: Mock
) -> AsyncGenerator[LangchainLLMClient, None]:
"""Provide initialized service."""
service = LangchainLLMClient(test_config)
service._model = mock_model # Inject mock
await service.initialize()
yield service
await service.cleanup()
@pytest.mark.asyncio
async def test_complete_with_retry(
self,
initialized_service: LangchainLLMClient,
mock_model: Mock
) -> None:
"""Test completion with retry logic."""
# Arrange
mock_model.ainvoke.side_effect = [
RateLimitError("Rate limited"),
AIMessage(content="Success after retry")
]
# Act
result = await initialized_service.complete("Test prompt")
# Assert
assert result == "Success after retry"
assert mock_model.ainvoke.call_count == 2
@pytest.mark.asyncio
async def test_streaming_response(
self,
initialized_service: LangchainLLMClient,
mock_model: Mock
) -> None:
"""Test streaming response handling."""
# Arrange
async def mock_stream():
for chunk in ["Hello", " ", "world"]:
yield AIMessageChunk(content=chunk)
mock_model.astream.return_value = mock_stream()
# Act
chunks = []
async for chunk in initialized_service.stream("Test"):
chunks.append(chunk)
# Assert
assert len(chunks) == 3
assert "".join(chunks) == "Hello world"
```
### 3. Service Error Handling
```python
# Pattern for comprehensive error testing
class TestServiceErrorHandling:
"""Test service error scenarios."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error,expected_handling",
[
(
RateLimitError("429 Too Many Requests"),
{"should_retry": True, "wait_time": 60}
),
(
TimeoutError("Request timeout"),
{"should_retry": True, "wait_time": 0}
),
(
ValueError("Invalid input"),
{"should_retry": False, "should_raise": True}
),
]
)
async def test_error_handling_strategies(
self,
service: LangchainLLMClient,
error: Exception,
expected_handling: Dict[str, Any]
) -> None:
"""Test different error handling strategies."""
# Arrange
service._model.ainvoke.side_effect = error
# Act & Assert
if expected_handling.get("should_raise"):
with pytest.raises(type(error)):
await service.complete("Test")
else:
# Service should handle internally
result = await service.complete("Test")
assert result is not None
```
## Graph Testing Patterns
### 1. Graph Construction Testing
```python
# tests/unit/biz_bud/graphs/test_research.py
class TestResearchGraphConstruction:
"""Test graph construction and configuration."""
def test_graph_structure(self) -> None:
"""Test graph has correct structure."""
# Act
graph = create_research_graph()
# Assert
# Verify nodes
expected_nodes = {
"parse_input", "generate_queries", "search",
"extract", "synthesize", "output"
}
assert set(graph.nodes.keys()) == expected_nodes
# Verify edges
assert graph.edges["parse_input"] == ["generate_queries"]
assert graph.edges["generate_queries"] == ["search"]
# Verify conditional edges
assert "should_continue_search" in graph.conditional_edges
def test_graph_state_schema(self) -> None:
"""Test graph uses correct state schema."""
# Act
graph = create_research_graph()
# Assert
assert graph.state_schema == ResearchState
```
### 2. Graph Execution Testing
```python
# tests/workflow/graphs/test_research_e2e.py
class TestResearchGraphExecution:
"""End-to-end tests for research graph."""
@pytest.fixture
def mock_external_services(self) -> Dict[str, Mock]:
"""Mock all external services."""
return {
"search": WebSearchMockBuilder()
.with_query_mapping({
"AI": [SearchResultFactory.create(title="AI Overview")],
"ML": [SearchResultFactory.create(title="ML Basics")],
})
.build(),
"scraper": ScraperMockBuilder()
.with_url_content({
"https://example.com/ai": "Artificial Intelligence content...",
"https://example.com/ml": "Machine Learning content...",
})
.build(),
}
@pytest.mark.asyncio
@pytest.mark.slow
async def test_complete_research_flow(
self,
mock_external_services: Dict[str, Mock],
graph_runner: GraphRunner
) -> None:
"""Test complete research workflow."""
# Arrange
graph = create_research_graph()
initial_state = StateFactory.create_research_state(
query="Explain AI and ML"
)
# Act
result = await graph_runner.run(
graph,
initial_state,
expected_status="success"
)
# Assert
assert result["synthesis"] != ""
assert len(result["search_results"]) > 0
assert len(result["extracted_info"]) > 0
# Verify service interactions
assert mock_external_services["search"].search.called
assert mock_external_services["scraper"].scrape.called
```
### 3. Graph Error Propagation
```python
# Pattern for testing error handling in graphs
class TestGraphErrorHandling:
"""Test error propagation in graphs."""
@pytest.mark.asyncio
async def test_node_error_propagation(
self,
graph_runner: GraphRunner
) -> None:
"""Test that node errors are properly handled."""
# Arrange
graph = create_research_graph()
# Force error in search node
with patch("biz_bud.nodes.research.search.search_node") as mock_node:
mock_node.side_effect = Exception("Search failed")
initial_state = StateFactory.create_research_state()
# Act
result = await graph_runner.run_with_error(
graph,
initial_state,
expected_error_type="node_error"
)
# Assert
assert result["status"] == "error"
assert any(
"Search failed" in e["message"]
for e in result["errors"]
)
```
## Integration Testing Patterns
### 1. Cross-Service Integration
```python
# tests/integration/test_llm_with_cache.py
class TestLLMWithCache:
"""Test LLM service with caching integration."""
@pytest.fixture
async def real_cache(self) -> AsyncGenerator[Cache, None]:
"""Provide real cache instance."""
cache = Cache(Redis(db=15)) # Test DB
await cache.initialize()
yield cache
await cache.clear()
@pytest.fixture
def llm_with_cache(
self,
real_cache: Cache,
mock_llm: Mock
) -> LLMServiceWithCache:
"""LLM service with real cache."""
return LLMServiceWithCache(
llm_client=mock_llm,
cache=real_cache
)
@pytest.mark.asyncio
async def test_cache_hit_flow(
self,
llm_with_cache: LLMServiceWithCache,
mock_llm: Mock
) -> None:
"""Test that cache prevents duplicate LLM calls."""
# Arrange
prompt = "What is caching?"
mock_llm.complete.return_value = "Caching is..."
# Act - First call
result1 = await llm_with_cache.complete_with_cache(prompt)
# Act - Second call (should hit cache)
result2 = await llm_with_cache.complete_with_cache(prompt)
# Assert
assert result1 == result2
mock_llm.complete.assert_called_once() # Only called once
```
### 2. Database Transaction Testing
```python
# Pattern for testing database transactions
class TestDatabaseTransactions:
"""Test database transaction handling."""
@pytest.mark.asyncio
async def test_transaction_rollback(
self,
db_service: DatabaseService
) -> None:
"""Test transaction rollback on error."""
# Arrange
initial_count = await db_service.count_records()
# Act
try:
async with db_service.transaction() as tx:
await tx.insert({"id": 1, "data": "test"})
# Force error
raise ValueError("Simulated error")
except ValueError:
pass
# Assert
final_count = await db_service.count_records()
assert final_count == initial_count # Rolled back
```
## Workflow Testing Patterns
### 1. Scenario-Based Testing
```python
# tests/workflow/scenarios/test_competitor_analysis.py
class TestCompetitorAnalysisScenario:
"""Test real-world competitor analysis scenario."""
@pytest.fixture
def scenario_data(self) -> Dict[str, Any]:
"""Load scenario test data."""
return {
"company": "TechStartup Inc",
"competitors": ["BigTech Corp", "InnovateCo"],
"market": "AI Software",
"expected_insights": [
"market_share",
"pricing_strategy",
"feature_comparison"
]
}
@pytest.mark.asyncio
@pytest.mark.slow
async def test_competitor_analysis_workflow(
self,
scenario_data: Dict[str, Any],
workflow_runner: WorkflowRunner
) -> None:
"""Test complete competitor analysis workflow."""
# Arrange
initial_state = {
"query": f"Analyze competitors for {scenario_data['company']}",
"context": scenario_data
}
# Act
result = await workflow_runner.run_analysis_workflow(
initial_state
)
# Assert
assert result["status"] == "success"
# Verify all expected insights were generated
analysis = result["analysis"]
for insight in scenario_data["expected_insights"]:
assert insight in analysis
assert analysis[insight] is not None
```
### 2. Performance Testing
```python
# Pattern for testing performance requirements
class TestPerformanceRequirements:
"""Test performance and scalability."""
@pytest.mark.asyncio
@pytest.mark.slow
async def test_concurrent_request_handling(
self,
service: SearchService
) -> None:
"""Test service handles concurrent requests."""
# Arrange
queries = [f"query_{i}" for i in range(10)]
# Act
start_time = time.time()
results = await asyncio.gather(
*[service.search(q) for q in queries]
)
duration = time.time() - start_time
# Assert
assert len(results) == 10
assert all(r is not None for r in results)
assert duration < 5.0 # Should complete within 5 seconds
@pytest.mark.asyncio
async def test_memory_usage(
self,
service: DataProcessingService,
large_dataset: List[Dict]
) -> None:
"""Test memory efficiency with large datasets."""
# Arrange
import psutil
process = psutil.Process()
initial_memory = process.memory_info().rss
# Act
await service.process_batch(large_dataset)
# Assert
final_memory = process.memory_info().rss
memory_increase = final_memory - initial_memory
# Should not use more than 100MB for processing
assert memory_increase < 100 * 1024 * 1024
```
## Assertion Patterns
### 1. Custom Assertions
```python
# tests/helpers/assertions/custom_assertions.py
def assert_valid_search_results(results: List[SearchResult]) -> None:
"""Assert search results are valid."""
assert len(results) > 0, "No search results returned"
for result in results:
assert result.url.startswith("http"), f"Invalid URL: {result.url}"
assert len(result.title) > 0, "Empty title"
assert len(result.snippet) > 0, "Empty snippet"
assert 0.0 <= result.relevance_score <= 1.0, "Invalid relevance score"
def assert_state_transition(
initial_state: State,
final_state: State,
expected_changes: Dict[str, Any]
) -> None:
"""Assert state transitioned correctly."""
for key, expected_value in expected_changes.items():
assert key in final_state, f"Missing key: {key}"
assert final_state[key] == expected_value, (
f"Unexpected value for {key}: "
f"expected {expected_value}, got {final_state[key]}"
)
# Verify unchanged fields
for key in initial_state:
if key not in expected_changes:
assert initial_state[key] == final_state[key], (
f"Unexpected change in {key}"
)
```
### 2. Async Assertions
```python
# Pattern for async assertion helpers
async def assert_eventually(
condition: Callable[[], bool],
timeout: float = 5.0,
interval: float = 0.1,
message: str = "Condition not met"
) -> None:
"""Assert condition becomes true eventually."""
start_time = time.time()
while time.time() - start_time < timeout:
if condition():
return
await asyncio.sleep(interval)
raise AssertionError(f"{message} after {timeout}s")
# Usage
await assert_eventually(
lambda: mock_service.call_count >= 3,
timeout=2.0,
message="Service not called enough times"
)
```

View File

@@ -1,693 +0,0 @@
# Coverage Strategies
## Overview
Achieving and maintaining 90%+ test coverage requires strategic planning, proper tooling, and consistent practices. This guide provides comprehensive strategies for maximizing meaningful test coverage.
## Coverage Configuration
### 1. Base Configuration
```toml
# pyproject.toml
[tool.coverage.run]
source = ["src", "packages"]
branch = true
parallel = true
concurrency = ["thread", "multiprocessing", "asyncio"]
context = "${CONTEXT}"
relative_files = true
[tool.coverage.report]
fail_under = 90
precision = 2
show_missing = true
skip_covered = false
skip_empty = true
sort = "cover"
exclude_lines = [
# Standard pragmas
"pragma: no cover",
# Type checking blocks
"if TYPE_CHECKING:",
"if typing.TYPE_CHECKING:",
# Abstract methods
"@abstractmethod",
"@abc.abstractmethod",
# Protocol definitions
"class .*\\(Protocol\\):",
"class .*\\(protocol\\):",
# Defensive programming
"raise NotImplementedError",
"raise AssertionError",
# Main blocks
"if __name__ == .__main__.:",
# Debug-only code
"def __repr__",
"def __str__",
]
[tool.coverage.html]
directory = "coverage_html"
show_contexts = true
skip_covered = false
skip_empty = false
[tool.coverage.json]
output = "coverage.json"
pretty_print = true
show_contexts = true
[tool.coverage.xml]
output = "coverage.xml"
```
### 2. Per-Component Coverage Targets
```yaml
# .coverage-targets.yaml
coverage_targets:
# Core business logic - highest standards
core:
paths:
- "src/biz_bud/nodes/**/*.py"
- "src/biz_bud/services/**/*.py"
- "src/biz_bud/graphs/**/*.py"
target: 95
branch_target: 90
# Utilities - high but flexible
utilities:
paths:
- "packages/business-buddy-utils/**/*.py"
- "packages/business-buddy-tools/**/*.py"
target: 90
branch_target: 85
# Configuration - moderate coverage
configuration:
paths:
- "src/biz_bud/config/**/*.py"
target: 85
branch_target: 80
# Models and types - lower requirements
models:
paths:
- "src/biz_bud/models/**/*.py"
- "src/biz_bud/types/**/*.py"
- "src/biz_bud/states/**/*.py"
target: 80
branch_target: 75
# Experimental - minimal requirements
experimental:
paths:
- "src/biz_bud/experimental/**/*.py"
target: 70
branch_target: 60
```
## Coverage Analysis Strategies
### 1. Coverage Report Analysis
```python
# scripts/analyze_coverage.py
import json
from pathlib import Path
from typing import Dict, List, Tuple
def analyze_coverage_gaps(coverage_file: Path) -> Dict[str, List[str]]:
"""Analyze coverage report to find gaps."""
with open(coverage_file) as f:
data = json.load(f)
gaps = {}
for file_path, file_data in data["files"].items():
missing_lines = file_data.get("missing_lines", [])
missing_branches = file_data.get("missing_branches", [])
if missing_lines or missing_branches:
gaps[file_path] = {
"missing_lines": missing_lines,
"missing_branches": missing_branches,
"coverage": file_data["summary"]["percent_covered"]
}
return gaps
def prioritize_coverage_improvements(
gaps: Dict[str, Dict]
) -> List[Tuple[str, float]]:
"""Prioritize files for coverage improvement."""
priorities = []
for file_path, gap_data in gaps.items():
# Higher priority for core modules
priority_score = 100 - gap_data["coverage"]
if "nodes" in file_path or "services" in file_path:
priority_score *= 2.0 # Double priority for core
elif "utils" in file_path:
priority_score *= 1.5
elif "config" in file_path:
priority_score *= 1.2
priorities.append((file_path, priority_score))
return sorted(priorities, key=lambda x: x[1], reverse=True)
```
### 2. Branch Coverage Analysis
```python
# tools/branch_coverage_analyzer.py
from typing import Set, Dict, List
import ast
import astunparse
class BranchAnalyzer(ast.NodeVisitor):
"""Analyze code branches for coverage."""
def __init__(self):
self.branches: List[Dict] = []
self.current_function = None
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
"""Track function context."""
old_function = self.current_function
self.current_function = node.name
self.generic_visit(node)
self.current_function = old_function
def visit_If(self, node: ast.If) -> None:
"""Track if branches."""
self.branches.append({
"type": "if",
"function": self.current_function,
"line": node.lineno,
"condition": astunparse.unparse(node.test).strip(),
"has_else": bool(node.orelse)
})
self.generic_visit(node)
def analyze_file(self, file_path: Path) -> Dict[str, Any]:
"""Analyze branches in a file."""
with open(file_path) as f:
tree = ast.parse(f.read())
self.visit(tree)
return {
"total_branches": len(self.branches),
"if_statements": sum(1 for b in self.branches if b["type"] == "if"),
"branches_with_else": sum(1 for b in self.branches if b.get("has_else")),
"complex_conditions": [
b for b in self.branches
if len(b["condition"]) > 50
]
}
```
## Coverage Improvement Patterns
### 1. Missing Line Coverage
```python
# Pattern for identifying and testing missing lines
# Original code with coverage gaps
async def process_data(data: List[Dict]) -> Dict[str, Any]:
if not data: # Line covered
return {"status": "empty"} # Line NOT covered
results = []
for item in data: # Line covered
try:
processed = await process_item(item) # Line covered
results.append(processed) # Line covered
except ValueError as e: # Line NOT covered
logger.error(f"Invalid item: {e}") # Line NOT covered
continue # Line NOT covered
return {"status": "success", "results": results} # Line covered
# Test to cover missing lines
@pytest.mark.asyncio
async def test_process_data_with_empty_input():
"""Cover empty data path."""
result = await process_data([])
assert result == {"status": "empty"}
@pytest.mark.asyncio
async def test_process_data_with_invalid_item():
"""Cover error handling path."""
# Mock to raise ValueError
with patch("module.process_item") as mock:
mock.side_effect = ValueError("Invalid")
result = await process_data([{"id": 1}])
assert result["status"] == "success"
assert len(result["results"]) == 0
```
### 2. Missing Branch Coverage
```python
# Pattern for covering all branches
# Complex branching logic
def calculate_score(
value: float,
category: str,
bonus: bool = False
) -> float:
score = value
if category == "premium":
score *= 1.5
elif category == "standard":
score *= 1.0
else: # Branch often missed
score *= 0.8
if bonus and score > 100: # Complex condition
score += 50
elif bonus: # Often missed branch
score += 10
return min(score, 200) # Boundary condition
# Comprehensive branch tests
@pytest.mark.parametrize(
"value,category,bonus,expected",
[
# Test all category branches
(100, "premium", False, 150),
(100, "standard", False, 100),
(100, "basic", False, 80),
# Test bonus branches
(100, "premium", True, 200), # Capped at 200
(50, "standard", True, 60), # Bonus without cap
# Edge cases
(0, "premium", False, 0),
(150, "premium", True, 200), # Test capping
],
ids=[
"premium_no_bonus",
"standard_no_bonus",
"other_no_bonus",
"premium_with_bonus_capped",
"standard_with_bonus",
"zero_value",
"max_cap"
]
)
def test_calculate_score_branches(
value: float,
category: str,
bonus: bool,
expected: float
) -> None:
"""Test all branches in calculate_score."""
assert calculate_score(value, category, bonus) == expected
```
### 3. Exception Path Coverage
```python
# Pattern for covering exception paths
# Code with multiple exception paths
async def fetch_and_process(url: str) -> Dict[str, Any]:
try:
response = await http_client.get(url)
data = response.json()
except asyncio.TimeoutError:
return {"error": "timeout"}
except aiohttp.ClientError as e:
return {"error": f"client_error: {str(e)}"}
except json.JSONDecodeError:
return {"error": "invalid_json"}
except Exception as e:
logger.error(f"Unexpected error: {e}")
return {"error": "unexpected"}
try:
return process_response(data)
except ValidationError as e:
return {"error": f"validation: {str(e)}"}
# Test each exception path
class TestFetchAndProcessErrors:
"""Test all error paths."""
@pytest.mark.asyncio
async def test_timeout_error(self, mock_client):
"""Test timeout handling."""
mock_client.get.side_effect = asyncio.TimeoutError()
result = await fetch_and_process("http://test.com")
assert result == {"error": "timeout"}
@pytest.mark.asyncio
async def test_client_error(self, mock_client):
"""Test client error handling."""
mock_client.get.side_effect = aiohttp.ClientError("Connection failed")
result = await fetch_and_process("http://test.com")
assert result["error"].startswith("client_error:")
@pytest.mark.asyncio
async def test_json_error(self, mock_client):
"""Test JSON parsing error."""
mock_response = Mock()
mock_response.json.side_effect = json.JSONDecodeError("Invalid", "", 0)
mock_client.get.return_value = mock_response
result = await fetch_and_process("http://test.com")
assert result == {"error": "invalid_json"}
@pytest.mark.asyncio
async def test_validation_error(self, mock_client):
"""Test validation error."""
mock_client.get.return_value.json.return_value = {"invalid": "data"}
with patch("module.process_response") as mock_process:
mock_process.side_effect = ValidationError("Invalid data")
result = await fetch_and_process("http://test.com")
assert "validation:" in result["error"]
```
## Advanced Coverage Techniques
### 1. Context-Based Coverage
```python
# Use coverage contexts to track test sources
# pytest.ini
[pytest]
addopts = --cov-context=test
# Run with context
# pytest --cov --cov-context=test
# Analyze which tests cover which code
def analyze_test_coverage_mapping():
"""Map tests to code coverage."""
with open("coverage.json") as f:
data = json.load(f)
test_to_code = {}
for file_path, file_data in data["files"].items():
for line, contexts in file_data.get("contexts", {}).items():
for context in contexts:
if context not in test_to_code:
test_to_code[context] = set()
test_to_code[context].add(f"{file_path}:{line}")
return test_to_code
```
### 2. Mutation Testing
```python
# Use mutation testing to verify test quality
# pip install mutmut
# Run mutation tests
# mutmut run --paths-to-mutate=src/biz_bud/services
# Example mutations that should be caught
class ServiceToTest:
def calculate(self, a: int, b: int) -> int:
if a > b: # Mutation: change > to >=
return a - b # Mutation: change - to +
return b - a # Mutation: return 0
# Tests should catch all mutations
def test_calculate_greater():
service = ServiceToTest()
assert service.calculate(5, 3) == 2
def test_calculate_lesser():
service = ServiceToTest()
assert service.calculate(3, 5) == 2
def test_calculate_equal():
service = ServiceToTest()
assert service.calculate(5, 5) == 0
```
### 3. Coverage for Async Code
```python
# Special considerations for async coverage
# Async generator coverage
async def stream_data(source: AsyncIterator[str]) -> AsyncIterator[str]:
async for item in source: # Cover iteration
if not item: # Cover empty check
continue
try:
processed = await process(item) # Cover processing
yield processed # Cover yield
except ProcessingError: # Cover error
logger.error(f"Failed to process: {item}")
yield f"ERROR: {item}" # Cover error yield
# Test async generator coverage
@pytest.mark.asyncio
async def test_stream_data_coverage():
"""Ensure all paths in async generator are covered."""
# Create test source
async def test_source():
yield "data1"
yield "" # Empty item
yield "data2"
# Mock processing to fail on data2
with patch("module.process") as mock:
mock.side_effect = ["processed1", ProcessingError("Failed")]
results = []
async for item in stream_data(test_source()):
results.append(item)
assert results == ["processed1", "ERROR: data2"]
```
## Coverage Maintenance Strategies
### 1. Automated Coverage Checks
```yaml
# .github/workflows/coverage.yml
name: Coverage Check
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install -e ".[test]"
- name: Run tests with coverage
run: |
pytest --cov --cov-report=xml --cov-report=term
- name: Check coverage targets
run: |
python scripts/check_coverage_targets.py
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: true
- name: Comment PR with coverage
if: github.event_name == 'pull_request'
uses: py-cov-action/python-coverage-comment-action@v3
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### 2. Coverage Trending
```python
# scripts/coverage_trend.py
import json
from pathlib import Path
from datetime import datetime
from typing import List, Dict
def track_coverage_over_time(
coverage_file: Path,
history_file: Path
) -> Dict[str, Any]:
"""Track coverage trends over time."""
# Load current coverage
with open(coverage_file) as f:
current = json.load(f)
# Load history
history = []
if history_file.exists():
with open(history_file) as f:
history = json.load(f)
# Add current data point
data_point = {
"timestamp": datetime.utcnow().isoformat(),
"total_coverage": current["totals"]["percent_covered"],
"branch_coverage": current["totals"].get("percent_branch_covered", 0),
"files": current["totals"]["num_files"],
"lines": current["totals"]["num_statements"]
}
history.append(data_point)
# Keep last 30 data points
history = history[-30:]
# Save updated history
with open(history_file, "w") as f:
json.dump(history, f, indent=2)
# Calculate trends
if len(history) >= 2:
trend = history[-1]["total_coverage"] - history[-2]["total_coverage"]
return {
"current": data_point,
"trend": trend,
"improving": trend > 0
}
return {"current": data_point, "trend": 0, "improving": True}
```
### 3. Coverage Reporting Dashboard
```python
# scripts/generate_coverage_report.py
from jinja2 import Template
import json
from pathlib import Path
REPORT_TEMPLATE = """
# Coverage Report - {{ date }}
## Summary
- **Total Coverage**: {{ total_coverage }}% ({{ coverage_trend }})
- **Branch Coverage**: {{ branch_coverage }}%
- **Files**: {{ num_files }}
- **Lines**: {{ num_lines }}
## Component Coverage
{% for component in components %}
### {{ component.name }}
- Coverage: {{ component.coverage }}% (Target: {{ component.target }}%)
- Status: {{ "✅ PASS" if component.coverage >= component.target else "❌ FAIL" }}
- Missing: {{ component.missing_lines }} lines
{% endfor %}
## Top Files Needing Coverage
{% for file in files_needing_coverage[:10] %}
1. `{{ file.path }}` - {{ file.coverage }}% ({{ file.missing }} lines missing)
{% endfor %}
## Recent Changes
{% for change in recent_changes %}
- {{ change.file }}: {{ change.old_coverage }}%{{ change.new_coverage }}%
{% endfor %}
"""
def generate_report(coverage_data: Dict, output_path: Path) -> None:
"""Generate coverage report."""
template = Template(REPORT_TEMPLATE)
report_data = {
"date": datetime.utcnow().strftime("%Y-%m-%d"),
"total_coverage": coverage_data["totals"]["percent_covered"],
"branch_coverage": coverage_data["totals"].get("percent_branch_covered", 0),
"num_files": coverage_data["totals"]["num_files"],
"num_lines": coverage_data["totals"]["num_statements"],
"coverage_trend": calculate_trend(coverage_data),
"components": analyze_components(coverage_data),
"files_needing_coverage": find_low_coverage_files(coverage_data),
"recent_changes": get_recent_changes(coverage_data)
}
report = template.render(**report_data)
output_path.write_text(report)
```
## Coverage Best Practices
### 1. Focus on Meaningful Coverage
```python
# ❌ BAD: Testing for coverage numbers
def test_trivial_getter():
obj = MyClass()
assert obj.name == obj.name # Meaningless
# ✅ GOOD: Testing actual behavior
def test_name_validation():
obj = MyClass()
with pytest.raises(ValueError):
obj.name = "" # Empty names not allowed
```
### 2. Test Business Logic Thoroughly
```python
# Prioritize coverage for business-critical paths
class PricingService:
def calculate_price(
self,
base_price: float,
customer_type: str,
quantity: int
) -> float:
"""Critical business logic - needs 100% coverage."""
# Every path here should be tested
pass
```
### 3. Use Coverage as a Guide
```python
# Coverage highlights untested edge cases
def process_batch(items: List[Item]) -> List[Result]:
if not items: # Coverage shows this is never tested
return []
results = []
for i, item in enumerate(items):
if i > 1000: # Coverage reveals this is never reached
raise BatchSizeError("Batch too large")
results.append(process_item(item))
return results
```

View File

@@ -1,793 +0,0 @@
# CI/CD Integration
## Overview
Integrating testing into the CI/CD pipeline ensures code quality, prevents regressions, and maintains high standards automatically. This guide covers comprehensive CI/CD testing strategies for the BizBud project.
## GitHub Actions Configuration
### 1. Main Test Workflow
```yaml
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
- cron: '0 2 * * *' # Nightly runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Quick checks first
lint-and-type:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install -e ".[dev,test]"
- name: Run ruff
run: ruff check .
- name: Run black
run: black --check .
- name: Run pyrefly
run: pyrefly check src packages
# Unit tests run in parallel
unit-tests:
needs: lint-and-type
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.11', '3.12']
test-group: [biz_bud, packages]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
pip install -e ".[test]"
- name: Run unit tests
run: |
pytest tests/unit/${{ matrix.test-group }} \
-m "not slow" \
--cov=src --cov=packages \
--cov-report=xml \
--junit-xml=junit-${{ matrix.test-group }}.xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unit,${{ matrix.test-group }},${{ matrix.os }}
name: unit-${{ matrix.test-group }}-${{ matrix.os }}
# Integration tests
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install -e ".[test,integration]"
- name: Run integration tests
env:
REDIS_URL: redis://localhost:6379/0
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
run: |
pytest tests/integration \
--cov=src --cov=packages \
--cov-report=xml \
--junit-xml=junit-integration.xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: integration
name: integration-tests
# Workflow tests (slower)
workflow-tests:
needs: integration-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
pip install -e ".[test,integration]"
- name: Run workflow tests
run: |
pytest tests/workflow \
-m "workflow" \
--cov=src --cov=packages \
--cov-report=xml \
--junit-xml=junit-workflow.xml \
--timeout=300
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: workflow
name: workflow-tests
# Coverage analysis
coverage-analysis:
needs: [unit-tests, integration-tests, workflow-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download coverage reports
uses: actions/download-artifact@v3
with:
name: coverage-reports
path: ./coverage
- name: Merge coverage reports
run: |
pip install coverage
coverage combine coverage/.coverage.*
coverage report
coverage html
- name: Check coverage thresholds
run: |
python scripts/check_coverage_targets.py
- name: Upload HTML report
uses: actions/upload-artifact@v3
with:
name: coverage-html
path: htmlcov/
- name: Comment PR
if: github.event_name == 'pull_request'
uses: py-cov-action/python-coverage-comment-action@v3
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### 2. Performance Testing Workflow
```yaml
# .github/workflows/performance.yml
name: Performance Tests
on:
pull_request:
types: [opened, synchronize]
schedule:
- cron: '0 4 * * 1' # Weekly on Monday
jobs:
performance-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for comparisons
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install -e ".[test,benchmark]"
- name: Run benchmarks
run: |
pytest tests/performance \
--benchmark-only \
--benchmark-json=benchmark.json \
--benchmark-compare=HEAD~1 \
--benchmark-compare-fail=min:10%
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'pytest'
output-file-path: benchmark.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
alert-threshold: '110%'
comment-on-alert: true
fail-on-alert: true
```
### 3. Security Testing Workflow
```yaml
# .github/workflows/security.yml
name: Security Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 3 * * *'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
- name: Run Bandit security linter
run: |
pip install bandit
bandit -r src packages -f json -o bandit-results.json
- name: Run safety check
run: |
pip install safety
safety check --json > safety-results.json || true
- name: Check for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
```
## Test Automation Strategies
### 1. Parallel Execution
```yaml
# Matrix strategy for parallel execution
test-matrix:
strategy:
matrix:
include:
- name: "Core Nodes"
path: "tests/unit/biz_bud/nodes"
markers: "not slow"
- name: "Services"
path: "tests/unit/biz_bud/services"
markers: "not slow and not external_api"
- name: "Utils"
path: "tests/unit/packages/business_buddy_utils"
markers: ""
- name: "Tools"
path: "tests/unit/packages/business_buddy_tools"
markers: "not external_api"
```
### 2. Conditional Testing
```yaml
# Run different tests based on changes
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.filter.outputs.core }}
utils: ${{ steps.filter.outputs.utils }}
tools: ${{ steps.filter.outputs.tools }}
steps:
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
core:
- 'src/biz_bud/**'
utils:
- 'packages/business-buddy-utils/**'
tools:
- 'packages/business-buddy-tools/**'
test-core:
needs: detect-changes
if: needs.detect-changes.outputs.core == 'true'
runs-on: ubuntu-latest
steps:
- run: pytest tests/unit/biz_bud tests/integration
```
### 3. Test Result Management
```python
# scripts/process_test_results.py
import xml.etree.ElementTree as ET
from pathlib import Path
import json
def process_junit_results(junit_file: Path) -> Dict[str, Any]:
"""Process JUnit XML results."""
tree = ET.parse(junit_file)
root = tree.getroot()
results = {
"total": int(root.attrib.get("tests", 0)),
"passed": 0,
"failed": 0,
"skipped": 0,
"errors": 0,
"time": float(root.attrib.get("time", 0)),
"failures": []
}
for testcase in root.findall(".//testcase"):
failure = testcase.find("failure")
error = testcase.find("error")
skipped = testcase.find("skipped")
if failure is not None:
results["failed"] += 1
results["failures"].append({
"test": testcase.attrib["name"],
"class": testcase.attrib.get("classname", ""),
"message": failure.attrib.get("message", ""),
"type": failure.attrib.get("type", "")
})
elif error is not None:
results["errors"] += 1
elif skipped is not None:
results["skipped"] += 1
else:
results["passed"] += 1
return results
def generate_summary(results: List[Dict[str, Any]]) -> str:
"""Generate test summary for PR comment."""
total = sum(r["total"] for r in results)
passed = sum(r["passed"] for r in results)
failed = sum(r["failed"] for r in results)
summary = f"""
## Test Results Summary
- **Total Tests**: {total}
- **Passed**: {passed}
- **Failed**: {failed}
- **Success Rate**: {(passed/total)*100:.1f}%
### Failed Tests
"""
for result in results:
for failure in result["failures"]:
summary += f"\n- `{failure['class']}.{failure['test']}`: {failure['message']}"
return summary
```
## Environment Management
### 1. Test Environment Setup
```yaml
# docker-compose.test.yml
version: '3.8'
services:
test-redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
test-postgres:
image: postgres:15
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser"]
interval: 5s
timeout: 3s
retries: 5
test-qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
environment:
QDRANT__SERVICE__HTTP_PORT: 6333
```
### 2. Environment Variables
```python
# scripts/setup_test_env.py
import os
from pathlib import Path
def setup_test_environment():
"""Set up test environment variables."""
env_vars = {
# API Keys (test/mock values)
"OPENAI_API_KEY": "test-key-openai",
"ANTHROPIC_API_KEY": "test-key-anthropic",
# Service URLs
"REDIS_URL": "redis://localhost:6379/15",
"DATABASE_URL": "postgresql://testuser:testpass@localhost:5432/testdb",
"QDRANT_URL": "http://localhost:6333",
# Test flags
"TESTING": "true",
"MOCK_EXTERNAL_APIS": "true",
"LOG_LEVEL": "DEBUG",
# Coverage
"COVERAGE_CORE": "src",
"COVERAGE_PACKAGES": "packages",
}
# Write .env.test file
env_file = Path(".env.test")
with open(env_file, "w") as f:
for key, value in env_vars.items():
f.write(f"{key}={value}\n")
# Set in current environment
os.environ.update(env_vars)
```
## Test Reporting
### 1. Allure Integration
```yaml
# Allure test reporting
- name: Run tests with Allure
run: |
pytest --alluredir=allure-results
- name: Generate Allure report
if: always()
run: |
allure generate allure-results -o allure-report --clean
- name: Upload Allure report
uses: actions/upload-artifact@v3
if: always()
with:
name: allure-report
path: allure-report/
```
### 2. Custom Test Reports
```python
# scripts/generate_test_report.py
from datetime import datetime
import json
from pathlib import Path
def generate_html_report(test_results: Dict[str, Any]) -> str:
"""Generate HTML test report."""
html_template = """
<!DOCTYPE html>
<html>
<head>
<title>Test Report - {date}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.summary {{ background-color: #f0f0f0; padding: 20px; border-radius: 5px; }}
.passed {{ color: green; }}
.failed {{ color: red; }}
.skipped {{ color: orange; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #4CAF50; color: white; }}
</style>
</head>
<body>
<h1>Test Report</h1>
<div class="summary">
<h2>Summary</h2>
<p>Date: {date}</p>
<p>Total Tests: {total}</p>
<p class="passed">Passed: {passed}</p>
<p class="failed">Failed: {failed}</p>
<p class="skipped">Skipped: {skipped}</p>
<p>Duration: {duration:.2f} seconds</p>
</div>
<h2>Test Results</h2>
<table>
<tr>
<th>Test Suite</th>
<th>Test Name</th>
<th>Status</th>
<th>Duration</th>
<th>Message</th>
</tr>
{test_rows}
</table>
</body>
</html>
"""
test_rows = ""
for suite, tests in test_results["suites"].items():
for test in tests:
status_class = test["status"].lower()
test_rows += f"""
<tr>
<td>{suite}</td>
<td>{test['name']}</td>
<td class="{status_class}">{test['status']}</td>
<td>{test['duration']:.3f}s</td>
<td>{test.get('message', '')}</td>
</tr>
"""
return html_template.format(
date=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
total=test_results["total"],
passed=test_results["passed"],
failed=test_results["failed"],
skipped=test_results["skipped"],
duration=test_results["duration"],
test_rows=test_rows
)
```
## Continuous Improvement
### 1. Test Metrics Dashboard
```yaml
# .github/workflows/metrics.yml
name: Test Metrics
on:
schedule:
- cron: '0 0 * * 0' # Weekly
workflow_dispatch:
jobs:
collect-metrics:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Collect test metrics
run: |
python scripts/collect_test_metrics.py \
--output metrics.json
- name: Update metrics dashboard
run: |
python scripts/update_dashboard.py \
--metrics metrics.json \
--output docs/test-metrics.md
- name: Commit updates
uses: EndBug/add-and-commit@v9
with:
add: 'docs/test-metrics.md'
message: 'Update test metrics dashboard'
```
### 2. Flaky Test Detection
```python
# scripts/detect_flaky_tests.py
from collections import defaultdict
import json
from pathlib import Path
def analyze_test_history(history_dir: Path, threshold: int = 5) -> List[Dict]:
"""Analyze test history to find flaky tests."""
test_results = defaultdict(list)
# Load all test results
for result_file in history_dir.glob("*.json"):
with open(result_file) as f:
data = json.load(f)
for test_name, result in data["tests"].items():
test_results[test_name].append(result["status"])
# Find flaky tests
flaky_tests = []
for test_name, results in test_results.items():
if len(results) >= threshold:
# Check for inconsistent results
unique_results = set(results[-threshold:])
if len(unique_results) > 1:
flaky_tests.append({
"test": test_name,
"results": results[-threshold:],
"failure_rate": results.count("failed") / len(results)
})
return sorted(flaky_tests, key=lambda x: x["failure_rate"], reverse=True)
```
## Pre-commit Hooks
### 1. Pre-commit Configuration
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest-check
name: pytest-check
entry: pytest
language: system
pass_filenames: false
always_run: true
args: ["-x", "--lf", "--ff", "-m", "not slow"]
- id: pyrefly-check
name: pyrefly-check
entry: pyrefly
language: system
types: [python]
require_serial: true
args: ["check", "src", "packages"]
- id: coverage-check
name: coverage-check
entry: coverage
language: system
pass_filenames: false
always_run: true
args: ["report", "--fail-under=90"]
```
### 2. Git Hooks Script
```bash
#!/bin/bash
# .git/hooks/pre-push
# Run quick tests before push
echo "Running quick tests..."
pytest -x -m "not slow" --tb=short
if [ $? -ne 0 ]; then
echo "Tests failed! Push aborted."
exit 1
fi
# Check coverage
echo "Checking coverage..."
coverage report --fail-under=90
if [ $? -ne 0 ]; then
echo "Coverage below threshold! Push aborted."
exit 1
fi
echo "All checks passed!"
```
## Best Practices
### 1. Test Isolation in CI
```yaml
# Ensure tests are isolated
env:
PYTEST_RANDOM_ORDER: true # Randomize test order
PYTEST_XDIST_WORKER_COUNT: auto # Parallel execution
PYTEST_TIMEOUT: 300 # Global timeout
```
### 2. Caching Strategy
```yaml
# Efficient caching
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
~/.cache/pip
~/.cache/pre-commit
.pytest_cache
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
```
### 3. Fail Fast Strategy
```yaml
# Stop on first failure in critical paths
strategy:
fail-fast: true
matrix:
test-suite: [unit, integration]

View File

@@ -1,865 +0,0 @@
# Implementation Guide
## Overview
This guide provides a step-by-step approach to implementing the comprehensive testing architecture for the BizBud project. Follow these steps to establish a robust testing framework from scratch.
## Phase 1: Foundation Setup (Week 1)
### Step 1: Project Configuration
```bash
# 1. Update pyproject.toml with testing dependencies
```
```toml
# pyproject.toml
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"pytest-timeout>=2.2.0",
"pytest-xdist>=3.5.0",
"pytest-benchmark>=4.0.0",
"coverage[toml]>=7.4.0",
"faker>=22.0.0",
"factory-boy>=3.3.0",
"hypothesis>=6.98.0",
]
dev = [
"mypy>=1.8.0",
"ruff>=0.1.0",
"black>=24.0.0",
"pre-commit>=3.6.0",
]
```
### Step 2: Create Test Directory Structure
```bash
# Create directory structure
mkdir -p tests/{unit,integration,workflow,helpers}
mkdir -p tests/unit/{biz_bud,packages}
mkdir -p tests/unit/biz_bud/{config,graphs,nodes,services,states,utils}
mkdir -p tests/unit/packages/{business_buddy_utils,business_buddy_tools}
mkdir -p tests/helpers/{factories,fixtures,mocks,assertions}
# Create __init__.py files
find tests -type d -exec touch {}/__init__.py \;
```
### Step 3: Root Configuration Files
```python
# tests/conftest.py
"""Root test configuration."""
import asyncio
import sys
from pathlib import Path
from typing import Generator
import pytest
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# Configure event loop
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
# Configure asyncio mode
pytest_plugins = ["pytest_asyncio"]
```
```ini
# tests/pytest.ini
[pytest]
minversion = 8.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Asyncio
asyncio_mode = auto
# Markers
markers =
unit: Unit tests
integration: Integration tests
workflow: Workflow tests
slow: Tests that take >5 seconds
external_api: Tests requiring external API calls
# Output
addopts =
--strict-markers
--strict-config
--verbose
-ra
--cov=src
--cov=packages
```
### Step 4: Initial Helper Modules
```python
# tests/helpers/factories/config_factories.py
"""Configuration factories for tests."""
from typing import Dict, Any, Optional
from biz_bud.config.models import AppConfig, APIKeys, LLMConfig
class ConfigFactory:
"""Factory for creating test configurations."""
@staticmethod
def create_api_keys(**overrides) -> APIKeys:
"""Create test API keys."""
defaults = {
"openai_api_key": "test-openai-key",
"anthropic_api_key": "test-anthropic-key",
"google_api_key": "test-google-key",
"tavily_api_key": "test-tavily-key",
}
return APIKeys(**{**defaults, **overrides})
@staticmethod
def create_app_config(**overrides) -> AppConfig:
"""Create test app configuration."""
api_keys = overrides.pop("api_keys", None) or ConfigFactory.create_api_keys()
defaults = {
"api_keys": api_keys,
"log_level": "DEBUG",
"redis_url": "redis://localhost:6379/15",
}
return AppConfig.model_validate({**defaults, **overrides})
```
## Phase 2: Core Testing Infrastructure (Week 2)
### Step 5: Mock Infrastructure
```python
# tests/helpers/mocks/base.py
"""Base mock utilities."""
from typing import Type, TypeVar, Any
from unittest.mock import Mock, AsyncMock, create_autospec
import asyncio
T = TypeVar('T')
class MockBuilder:
"""Base builder for creating mocks."""
def __init__(self, spec_class: Type[T]):
self.spec_class = spec_class
self.mock = create_autospec(spec_class, spec_set=True)
self._configure_async_methods()
def _configure_async_methods(self) -> None:
"""Convert async methods to AsyncMock."""
for attr_name in dir(self.spec_class):
if attr_name.startswith('_'):
continue
attr = getattr(self.spec_class, attr_name, None)
if asyncio.iscoroutinefunction(attr):
setattr(self.mock, attr_name, AsyncMock(spec=attr))
def build(self) -> Mock:
"""Return configured mock."""
return self.mock
```
```python
# tests/helpers/fixtures/service_fixtures.py
"""Service mock fixtures."""
import pytest
from unittest.mock import Mock
from tests.helpers.mocks import LLMMockBuilder, DatabaseMockBuilder
@pytest.fixture
def mock_llm_client() -> Mock:
"""Provide mock LLM client."""
return (
LLMMockBuilder()
.with_default_responses()
.build()
)
@pytest.fixture
def mock_database() -> Mock:
"""Provide mock database service."""
return (
DatabaseMockBuilder()
.with_empty_storage()
.build()
)
```
### Step 6: State Management Testing
```python
# tests/helpers/factories/state_factories.py
"""State factories for tests."""
from typing import Dict, Any, List, Optional
from uuid import uuid4
from datetime import datetime
from biz_bud.states import BaseState, ResearchState
from langchain_core.messages import BaseMessage, HumanMessage
class StateFactory:
"""Factory for creating test states."""
@staticmethod
def create_base_state(**overrides) -> BaseState:
"""Create base state with defaults."""
defaults = {
"messages": [],
"errors": [],
"config": {},
"status": "pending",
"thread_id": f"test-{uuid4().hex[:8]}",
"context": {},
"run_metadata": {
"created_at": datetime.utcnow().isoformat(),
"test_run": True,
},
"is_last_step": False,
}
# Merge defaults with overrides
state_data = {**defaults}
for key, value in overrides.items():
state_data[key] = value
return BaseState(**state_data)
```
### Step 7: First Unit Tests
```python
# tests/unit/biz_bud/config/test_models.py
"""Test configuration models."""
import pytest
from pydantic import ValidationError
from biz_bud.config.models import AppConfig, APIKeys, LLMConfig
from tests.helpers.factories import ConfigFactory
class TestConfigModels:
"""Test configuration model validation."""
def test_api_keys_validation(self):
"""Test API keys require all fields."""
# Valid creation
keys = ConfigFactory.create_api_keys()
assert keys.openai_api_key == "test-openai-key"
# Invalid - missing required field
with pytest.raises(ValidationError):
APIKeys(openai_api_key="key") # Missing other required keys
def test_app_config_defaults(self):
"""Test app config applies defaults."""
config = AppConfig.model_validate({
"api_keys": ConfigFactory.create_api_keys().model_dump()
})
# Check defaults applied
assert config.log_level == "INFO" # Default value
assert config.redis_url is not None
```
## Phase 3: Component Testing (Week 3)
### Step 8: Node Testing Implementation
```python
# tests/unit/biz_bud/nodes/core/test_input.py
"""Test input parsing node."""
import pytest
from typing import Dict, Any
from biz_bud.nodes.core.input import parse_input_node
from tests.helpers.factories import StateFactory
class TestParseInputNode:
"""Test the parse_input_node."""
@pytest.mark.asyncio
async def test_parse_simple_query(self):
"""Test parsing a simple query."""
# Arrange
state = StateFactory.create_base_state(
messages=[HumanMessage(content="What is quantum computing?")]
)
# Act
result = await parse_input_node(state, {})
# Assert
assert "parsed_query" in result
assert result["parsed_query"] == "What is quantum computing?"
assert result["query_type"] == "question"
@pytest.mark.asyncio
async def test_parse_empty_input(self):
"""Test handling empty input."""
# Arrange
state = StateFactory.create_base_state(messages=[])
# Act
result = await parse_input_node(state, {})
# Assert
assert "errors" in result
assert len(result["errors"]) > 0
assert result["errors"][0]["error_type"] == "empty_input"
```
### Step 9: Service Testing Implementation
```python
# tests/unit/biz_bud/services/llm/test_client.py
"""Test LLM client service."""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from biz_bud.services.llm.client import LangchainLLMClient
from biz_bud.config.models import LLMConfig
from tests.helpers.factories import ConfigFactory
class TestLangchainLLMClient:
"""Test LLM client functionality."""
@pytest.fixture
def llm_config(self) -> LLMConfig:
"""Create LLM config for tests."""
return LLMConfig(
model="gpt-4o-mini",
temperature=0.0,
max_tokens=100,
)
@pytest.fixture
def client(self, llm_config: LLMConfig) -> LangchainLLMClient:
"""Create LLM client instance."""
return LangchainLLMClient(llm_config)
@pytest.mark.asyncio
async def test_initialization(self, client: LangchainLLMClient):
"""Test client initialization."""
assert client.config.model == "gpt-4o-mini"
assert client.config.temperature == 0.0
@pytest.mark.asyncio
@patch("langchain_openai.ChatOpenAI")
async def test_complete_success(
self,
mock_chat_class: Mock,
client: LangchainLLMClient
):
"""Test successful completion."""
# Arrange
mock_model = AsyncMock()
mock_model.ainvoke.return_value = AIMessage(content="Test response")
mock_chat_class.return_value = mock_model
# Act
result = await client.complete("Test prompt")
# Assert
assert result == "Test response"
mock_model.ainvoke.assert_called_once()
```
## Phase 4: Integration Testing (Week 4)
### Step 10: Integration Test Setup
```python
# tests/integration/conftest.py
"""Integration test configuration."""
import pytest
from typing import AsyncGenerator
import aioredis
@pytest.fixture(scope="session")
def docker_services():
"""Ensure docker services are running."""
# This would integrate with pytest-docker
pass
@pytest.fixture
async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
"""Provide real Redis client for integration tests."""
client = await aioredis.create_redis_pool(
"redis://localhost:6379/15", # Test database
minsize=1,
maxsize=10
)
yield client
# Cleanup
await client.flushdb()
client.close()
await client.wait_closed()
```
### Step 11: Cross-Component Integration Tests
```python
# tests/integration/test_llm_with_cache.py
"""Test LLM with caching integration."""
import pytest
from typing import Dict, Any
from biz_bud.services.llm.client import LangchainLLMClient
from biz_bud.services.cache import CacheService
class TestLLMCacheIntegration:
"""Test LLM service with cache integration."""
@pytest.fixture
async def cache_service(self, redis_client) -> CacheService:
"""Create cache service with real Redis."""
service = CacheService(redis_client)
await service.initialize()
return service
@pytest.fixture
def llm_with_cache(
self,
llm_config: LLMConfig,
cache_service: CacheService
) -> LLMServiceWithCache:
"""Create LLM service with caching."""
return LLMServiceWithCache(
llm_config=llm_config,
cache=cache_service
)
@pytest.mark.asyncio
async def test_cache_prevents_duplicate_calls(
self,
llm_with_cache: LLMServiceWithCache,
mock_llm_model: Mock
):
"""Test that cache prevents duplicate LLM calls."""
# Arrange
prompt = "Explain caching"
mock_llm_model.complete.return_value = "Caching stores..."
# Act - First call
result1 = await llm_with_cache.complete_with_cache(prompt)
# Act - Second call (should use cache)
result2 = await llm_with_cache.complete_with_cache(prompt)
# Assert
assert result1 == result2
mock_llm_model.complete.assert_called_once()
```
## Phase 5: Workflow Testing (Week 5)
### Step 12: Graph Testing Setup
```python
# tests/workflow/conftest.py
"""Workflow test configuration."""
import pytest
from typing import Dict, Any
from unittest.mock import Mock
from biz_bud.graphs import create_research_graph
from tests.helpers.factories import StateFactory
@pytest.fixture
def research_graph():
"""Create research graph for testing."""
return create_research_graph()
@pytest.fixture
def workflow_runner(mock_services: Dict[str, Mock]):
"""Create workflow test runner."""
class WorkflowRunner:
def __init__(self, services: Dict[str, Mock]):
self.services = services
async def run(
self,
graph: Any,
initial_state: Dict[str, Any],
expected_status: str = "success"
) -> Dict[str, Any]:
"""Run workflow and verify status."""
config = {
"configurable": {
"services": self.services,
"thread_id": "test-run"
}
}
result = await graph.ainvoke(initial_state, config)
assert result["status"] == expected_status
return result
return WorkflowRunner(mock_services)
```
### Step 13: End-to-End Workflow Tests
```python
# tests/workflow/test_research_workflow.py
"""Test complete research workflow."""
import pytest
from typing import Dict, Any
from tests.helpers.factories import StateFactory, SearchResultFactory
class TestResearchWorkflow:
"""Test research graph end-to-end."""
@pytest.mark.asyncio
@pytest.mark.slow
async def test_successful_research_flow(
self,
research_graph,
workflow_runner,
mock_search_tool,
mock_scraper
):
"""Test complete research workflow."""
# Arrange
mock_search_tool.search.return_value = [
SearchResultFactory.create(
title="AI Overview",
url="https://example.com/ai"
)
]
mock_scraper.scrape.return_value = ScrapedContent(
content="Comprehensive AI content...",
url="https://example.com/ai"
)
initial_state = StateFactory.create_research_state(
query="What is artificial intelligence?"
)
# Act
result = await workflow_runner.run(
research_graph,
initial_state,
expected_status="success"
)
# Assert
assert result["synthesis"] != ""
assert len(result["search_results"]) > 0
assert "artificial intelligence" in result["synthesis"].lower()
```
## Phase 6: Coverage and Quality (Week 6)
### Step 14: Coverage Configuration
```bash
# Create coverage configuration
cat > .coveragerc << EOF
[run]
source = src,packages
branch = True
parallel = True
omit =
*/tests/*
*/__init__.py
[report]
precision = 2
show_missing = True
skip_covered = False
fail_under = 90
[html]
directory = htmlcov
EOF
```
### Step 15: Coverage Scripts
```python
# scripts/check_coverage.py
"""Check coverage meets targets."""
import json
import sys
from pathlib import Path
def check_coverage_targets(coverage_file: Path) -> bool:
"""Check if coverage meets targets."""
with open(coverage_file) as f:
data = json.load(f)
total_coverage = data["totals"]["percent_covered"]
# Define targets
targets = {
"src/biz_bud/nodes": 95,
"src/biz_bud/services": 95,
"packages/business-buddy-utils": 90,
"packages/business-buddy-tools": 90,
}
all_passed = True
for path_pattern, target in targets.items():
files = [f for f in data["files"] if path_pattern in f]
if files:
coverage = sum(
data["files"][f]["summary"]["percent_covered"]
for f in files
) / len(files)
if coverage < target:
print(f"{path_pattern}: {coverage:.1f}% < {target}%")
all_passed = False
else:
print(f"{path_pattern}: {coverage:.1f}% >= {target}%")
return all_passed
if __name__ == "__main__":
if not check_coverage_targets(Path("coverage.json")):
sys.exit(1)
```
## Phase 7: CI/CD Integration (Week 7)
### Step 16: GitHub Actions Setup
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[test,dev]"
- name: Run tests
run: |
pytest --cov --cov-report=xml --cov-report=term
- name: Upload coverage
uses: codecov/codecov-action@v4
```
### Step 17: Pre-commit Hooks
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- repo: https://github.com/psf/black
rev: 24.1.0
hooks:
- id: black
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.1.14
hooks:
- id: ruff
- repo: local
hooks:
- id: mypy
name: mypy
entry: mypy
language: system
types: [python]
require_serial: true
```
```bash
# Install pre-commit
pre-commit install
pre-commit run --all-files
```
## Phase 8: Maintenance and Evolution
### Step 18: Test Maintenance Scripts
```python
# scripts/test_health.py
"""Monitor test suite health."""
import subprocess
import json
from datetime import datetime
def generate_test_health_report():
"""Generate test suite health report."""
report = {
"date": datetime.utcnow().isoformat(),
"metrics": {}
}
# Run pytest with json output
result = subprocess.run(
["pytest", "--json-report", "--json-report-file=test-report.json"],
capture_output=True
)
with open("test-report.json") as f:
test_data = json.load(f)
# Calculate metrics
report["metrics"]["total_tests"] = test_data["summary"]["total"]
report["metrics"]["passed"] = test_data["summary"]["passed"]
report["metrics"]["failed"] = test_data["summary"]["failed"]
report["metrics"]["duration"] = test_data["duration"]
# Find slow tests
slow_tests = [
test for test in test_data["tests"]
if test["duration"] > 5.0
]
report["slow_tests"] = slow_tests
return report
```
### Step 19: Documentation Generation
```python
# scripts/generate_test_docs.py
"""Generate test documentation."""
import ast
from pathlib import Path
def extract_test_docstrings(test_file: Path) -> Dict[str, str]:
"""Extract docstrings from test file."""
with open(test_file) as f:
tree = ast.parse(f.read())
docs = {}
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
docstring = ast.get_docstring(node)
if docstring:
docs[node.name] = docstring
return docs
def generate_test_documentation():
"""Generate markdown documentation for tests."""
doc_content = ["# Test Documentation\n"]
for test_file in Path("tests").rglob("test_*.py"):
docs = extract_test_docstrings(test_file)
if docs:
module_path = test_file.relative_to("tests")
doc_content.append(f"\n## {module_path}\n")
for test_name, docstring in docs.items():
doc_content.append(f"### {test_name}\n")
doc_content.append(f"{docstring}\n")
Path("docs/test-documentation.md").write_text("\n".join(doc_content))
```
## Checklist
### Week 1: Foundation
- [ ] Configure pyproject.toml with test dependencies
- [ ] Create test directory structure
- [ ] Set up conftest.py and pytest.ini
- [ ] Create initial helper modules
### Week 2: Infrastructure
- [ ] Implement mock builders
- [ ] Create fixture architecture
- [ ] Set up state factories
- [ ] Write first unit tests
### Week 3: Components
- [ ] Test all node types
- [ ] Test service layer
- [ ] Test configuration system
- [ ] Achieve 50% coverage
### Week 4: Integration
- [ ] Set up integration test environment
- [ ] Test service interactions
- [ ] Test cross-package functionality
- [ ] Achieve 70% coverage
### Week 5: Workflows
- [ ] Test complete graphs
- [ ] Test error scenarios
- [ ] Test performance requirements
- [ ] Achieve 85% coverage
### Week 6: Quality
- [ ] Reach 90% coverage target
- [ ] Set up coverage monitoring
- [ ] Create quality scripts
- [ ] Document patterns
### Week 7: Automation
- [ ] Configure CI/CD
- [ ] Set up pre-commit hooks
- [ ] Create maintenance scripts
- [ ] Complete documentation
### Week 8: Polish
- [ ] Review and refactor tests
- [ ] Optimize test performance
- [ ] Create dashboards
- [ ] Train team
## Success Criteria
1. **Coverage**: 90%+ overall, 95%+ for core logic
2. **Performance**: All unit tests < 1s, integration < 5s
3. **Reliability**: No flaky tests, all tests deterministic
4. **Maintainability**: Clear patterns, good documentation
5. **Automation**: Full CI/CD integration, automated reporting

View File

@@ -1,84 +0,0 @@
# BizBud Testing Architecture
## Overview
This directory contains the comprehensive testing architecture documentation for the BizBud project. The testing framework is designed to ensure code quality, maintainability, and reliability through strict type safety, comprehensive coverage, and modular design.
## Quick Links
1. [Testing Philosophy](./01-testing-philosophy.md) - Core principles and guidelines
2. [Architecture Overview](./02-architecture-overview.md) - High-level testing architecture
3. [Directory Structure](./03-directory-structure.md) - Detailed test organization
4. [Fixture Architecture](./04-fixture-architecture.md) - Fixture design and patterns
5. [Mocking Strategies](./05-mocking-strategies.md) - Comprehensive mocking guide
6. [Testing Patterns](./06-testing-patterns.md) - Component-specific testing patterns
7. [Coverage Strategies](./07-coverage-strategies.md) - Achieving and maintaining 90%+ coverage
8. [CI/CD Integration](./08-ci-cd-integration.md) - Automation and continuous testing
9. [Implementation Guide](./09-implementation-guide.md) - Step-by-step implementation
## Key Features
- **Strict Type Safety**: All tests use proper type annotations with mypy strict mode
- **90%+ Coverage Target**: Comprehensive coverage with meaningful tests
- **Modular Architecture**: Tests mirror source code structure for easy navigation
- **Powerful Fixtures**: Reusable fixtures for mocking heavy operations
- **Multiple Test Levels**: Unit, integration, and workflow tests
- **Async-First**: Full support for async/await patterns
- **LLM-Compatible**: Documentation designed for AI-assisted development
## Quick Start
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov
# Run specific test level
pytest -m unit
pytest -m integration
pytest -m workflow
# Run tests for specific module
pytest tests/unit/biz_bud/services/
# Run with verbose output
pytest -vv
```
## Test Levels
### Unit Tests
- Test individual functions/methods in isolation
- Mock all external dependencies
- Fast execution (<1s per test)
- Location: `tests/unit/`
### Integration Tests
- Test interaction between components
- Mock external services (APIs, databases)
- Medium execution time (1-5s per test)
- Location: `tests/integration/`
### Workflow Tests
- Test complete business workflows
- Mock only external boundaries
- Slower execution (5-30s per test)
- Location: `tests/workflow/`
## Coverage Requirements
- **Overall**: 90% minimum
- **Core Business Logic**: 95% minimum
- **Utilities**: 90% minimum
- **New Code**: 95% minimum
## Technology Stack
- **Test Framework**: pytest 8.0+
- **Async Support**: pytest-asyncio
- **Coverage**: pytest-cov
- **Mocking**: unittest.mock + custom factories
- **Type Checking**: mypy with strict mode
- **Fixtures**: pytest fixtures with dependency injection

View File

@@ -1,96 +0,0 @@
# Clean Tests Implementation - Feedback Response
**Status**: ✅ All feedback items have been successfully implemented
This document summarizes the implementation of additional feedback from the updated `clean-tests.md` document.
## Completed Tasks
### 1. Created Tests for Untested Modules ✅
#### Menu Intelligence Graph Test
- **File**: `tests/unit_tests/graphs/test_menu_intelligence.py`
- **Coverage**:
- Routing logic tests (`route_after_identify`)
- Graph structure verification
- Flow tests for single ingredient and batch ingredients
- Error handling tests
#### Costs Module Test
- **File**: `packages/business-buddy-utils/tests/data/test_costs.py`
- **Coverage**:
- Cost constants validation
- Token count estimation
- LLM cost calculation with and without tiktoken
- Error handling and fallback behavior
### 2. Removed Legacy and Orphaned Tests ✅
- **Removed**: `packages/business-buddy-utils/tests/networking/test_http_client_legacy.py`
- Reason: Testing deprecated interface that was replaced by APIClient
- **Removed**: `tests/unit_tests/nodes/synthesis/test_prepare.py`
- Reason: The prepare_search_results node was removed from the graph
- **Updated**: `tests/unit_tests/graphs/test_research.py`
- Removed brittle `test_full_graph_execution` that mocked every node
- Added comment explaining why it was removed
### 3. Enhanced Main Graph Integration Test ✅
- **File**: `tests/integration_tests/graphs/test_main_graph_integration.py`
- **Renamed**: From generic `test_graph_integration.py` to be more specific
- **Added**: `test_graph_routes_to_error_node_on_critical_failure`
- Tests critical error routing logic
- Verifies error handler is called directly
- Ensures error state is preserved
### 4. Updated Underutilized Fixtures ✅
- **File**: `tests/unit_tests/nodes/analysis/test_data.py`
- **Changes**:
- Removed local `sample_df` and `minimal_state` fixtures
- Updated all tests to use shared fixtures:
- `sample_dataframe` from `mock_fixtures.py`
- `base_state` from `state_fixtures.py`
- Removed `BusinessBuddyState` import
- **Enhanced**: `tests/helpers/fixtures/mock_fixtures.py`
- Added `sample_dataframe` fixture for pandas DataFrame testing
### 5. Created Module-Scoped Fixtures ✅
- **File**: `tests/integration_tests/conftest.py`
- **Added Module-Scoped Fixtures**:
- `module_app_config` - Single config instance per module
- `module_service_factory` - Initialized once per module with cleanup
- `research_graph` - Compiled research graph cached per module
- `menu_intelligence_graph` - Compiled menu intelligence graph
- `main_graph` - Compiled main graph
## Benefits Achieved
1. **Better Test Coverage**: Critical business logic in menu intelligence and costs now has tests
2. **Cleaner Test Suite**: Removed outdated and brittle tests
3. **Improved Performance**: Module-scoped fixtures reduce redundant compilation and initialization
4. **Reduced Duplication**: Shared fixtures eliminate repetitive setup code
5. **More Maintainable**: Tests focus on behavior rather than implementation details
## Remaining Work
The only remaining item from the original clean-tests.md is creating tests for additional untested modules:
- bb_core caching modules
- bb_core logging modules
- bb_core networking modules
- bb_extraction modules
- Various service and node modules
This is a larger effort that would require explicit user direction to begin, as it involves creating many new test files.
## Test Results
All changes have been implemented successfully:
- ✅ All existing tests continue to pass
- ✅ New tests are passing
- ✅ No linting errors introduced
- ✅ Clean test architecture maintained

View File

@@ -1,127 +0,0 @@
# Clean Tests Implementation - Round 2 Feedback
**Status**: ✅ All feedback items have been successfully implemented
This document summarizes the implementation of the second round of feedback from the updated `clean-tests.md` document.
## Completed Tasks
### 1. Moved Misplaced Tests ✅
#### Extraction Tests
- **Moved**: `packages/business-buddy-utils/tests/extraction/``packages/business-buddy-extraction/tests/`
- **Reason**: Tests were in wrong package after refactoring
- **Impact**: Each package is now self-contained with its own tests
#### Menu Intelligence Architecture Test
- **Moved**: `tests/integration_tests/graphs/test_menu_direct_integration.py``tests/meta/test_menu_intelligence_architecture.py`
- **Reason**: Test was validating architecture rather than integration flow
- **Impact**: Clearer test categorization
### 2. Created Tests for Untested Modules ✅
Created comprehensive test files with placeholder and actual tests:
#### `test_prepare.py`
- Tests for `prepare_search_results` function
- Covers basic preparation, empty results, duplicate URLs, state preservation
#### `test_synthesize.py`
- Tests for `synthesize_search_results` function
- Covers valid synthesis, empty data, LLM errors, prompt usage
#### `test_extractors.py`
- Tests for extraction functions
- Covers key information extraction, content filtering, LLM-based extraction
#### `test_exceptions.py`
- Tests for LLM service exceptions
- Covers all exception types, error details extraction, retry logic
#### `test_url_analyzer.py`
- Tests for URL analysis functions
- Covers domain extraction, validation, normalization, categorization
#### `test_batch_process.py`
- Tests for RAG batch processing
- Covers document processing, chunking, embedding creation, vector storage
### 3. Moved Sample Data to Shared Fixtures ✅
#### Created `tests/unit_tests/graphs/conftest.py`
- Moved all sample data from `test_research.py` to shared fixtures
- Created comprehensive fixtures for synthesis testing
- Benefits:
- Centralized data definitions
- Reusable across tests
- Cleaner test functions
#### Updated `test_research.py`
- Removed hardcoded sample data
- Updated all test methods to use injected fixtures
- Tests now focus on logic rather than data setup
### 4. Updated Fixture Scopes for Performance ✅
Changed scope from `function` to `module` for read-only fixtures in `config_fixtures.py`:
- `app_config` - Module-scoped for reuse
- `logging_config` - Module-scoped
- `database_config` - Module-scoped
- `redis_config` - Module-scoped
- `vector_store_config` - Module-scoped
- `agent_config` - Module-scoped
- `llm_config` - Module-scoped
**Performance Impact**: These fixtures are now created once per module instead of for every test, significantly reducing test setup time.
## File Structure Changes
```
packages/
├── business-buddy-extraction/
│ └── tests/
│ └── extraction/ # Moved from bb-utils
└── business-buddy-utils/
└── tests/
└── extraction/ # REMOVED - moved to bb-extraction
tests/
├── meta/
│ └── test_menu_intelligence_architecture.py # Moved from integration_tests
├── unit_tests/
│ ├── graphs/
│ │ └── conftest.py # NEW - shared fixtures
│ ├── nodes/
│ │ ├── extraction/
│ │ │ └── test_extractors.py # NEW
│ │ ├── rag/
│ │ │ └── test_batch_process.py # NEW
│ │ ├── scraping/
│ │ │ └── test_url_analyzer.py # NEW
│ │ └── synthesis/
│ │ ├── test_prepare.py # NEW
│ │ └── test_synthesize.py # NEW
│ └── services/
│ └── llm/
│ └── test_exceptions.py # NEW
```
## Benefits Achieved
1. **Better Organization**: Tests are now in the correct packages and directories
2. **Improved Coverage**: Previously untested modules now have comprehensive tests
3. **Enhanced Performance**: Module-scoped fixtures reduce redundant setup
4. **Cleaner Code**: Shared fixtures eliminate duplication
5. **Maintainability**: Tests are more focused and easier to understand
## Next Steps
The only remaining task from the original feedback is creating tests for additional untested modules in the caching, logging, and networking areas. This is tracked as task #18 and would require explicit direction to proceed.
## Verification
All changes have been implemented and verified:
- ✅ File moves completed successfully
- ✅ New test files created with appropriate content
- ✅ Fixtures refactored and scopes updated
- ✅ Existing tests updated to use shared fixtures

View File

@@ -1,119 +0,0 @@
# Clean Tests Implementation Summary
This document summarizes the implementation of recommendations from `clean-tests.md`.
## Completed Tasks
### 1. Orphaned/Misplaced Tests Cleanup ✅
- **Merged** `tests/unit_tests/graphs/test_synthesis_once.py` into `test_research.py`
- The synthesis retry tests are now part of the main research test file
- Maintains test coverage while reducing file sprawl
- **Removed** empty `tests/integration_tests/tools/web/test_tavily_integration.py`
- File only contained TODO comments with no actual tests
- **Moved** meta-tests to dedicated directory
- Created `tests/meta/` directory
- Moved `test_fixture_architecture.py` and `test_simple_fixtures.py`
- Separated framework testing from application testing
- **Renamed** `test_r2r_multipage_upload_integration.py``test_upload_to_r2r_node_integration.py`
- Name now accurately reflects the test content (tests `upload_to_r2r_node`)
### 2. Shared Fixtures for Analysis Nodes ✅
Created `/tests/unit_tests/nodes/analysis/conftest.py` with shared fixtures:
```python
@pytest.fixture
def mock_llm_client() -> AsyncMock:
"""Provides a mock LLM client."""
@pytest.fixture
def mock_service_factory(mock_llm_client: AsyncMock) -> MagicMock:
"""Provides a mock ServiceFactory that returns a mock LLM client."""
@pytest.fixture
def analysis_state(mock_service_factory) -> dict:
"""Provides a base state dictionary for analysis nodes."""
```
Updated `test_interpret.py` and `test_plan.py` to use these shared fixtures, eliminating code duplication.
### 3. State Fixtures Hierarchy ✅
Created `/tests/helpers/fixtures/state_fixtures.py` with comprehensive state fixtures:
- `state_builder` - Provides StateBuilder instance
- `base_state` - Minimal valid state for most nodes
- `research_state` - Pre-populated for research workflows
- `url_to_rag_state` - Pre-populated for URL to RAG workflows
- `analysis_workflow_state` - Pre-populated for analysis workflows
- `menu_intelligence_state` - Pre-populated for menu intelligence
- `validated_state` - State that has passed validation
- `state_with_errors` - State with errors for error handling tests
- `state_with_search_results` - Research state with search results
- `state_with_extracted_info` - Research state with extracted info
- `completed_research_state` - Research state with completed synthesis
### 4. Complex Mock Fixtures ✅
Enhanced `/tests/helpers/fixtures/mock_fixtures.py` with Firecrawl and R2R mocks:
**Firecrawl Fixtures:**
- `mock_firecrawl_scrape_result` - Realistic successful scrape result
- `mock_firecrawl_batch_scrape_results` - Multiple scrape results
- `mock_firecrawl_error_result` - Error result for testing failures
- `mock_firecrawl_app` - Complete mock Firecrawl app with all methods
**R2R Fixtures:**
- `mock_r2r_upload_response` - Successful document upload response
- `mock_r2r_search_response` - Search/retrieval results
- `mock_r2r_collection_info` - Collection information
- `mock_r2r_client` - Complete mock R2R client with all methods
### 5. Missing __init__.py Files ✅
Created missing `__init__.py` files to fix pytest discovery:
- `/tests/unit_tests/nodes/__init__.py` (critical fix)
- Multiple other directories in the test structure
## Benefits Achieved
1. **Better Organization**: Tests are now logically grouped, meta-tests separated
2. **Reduced Duplication**: Shared fixtures eliminate repetitive mock setup
3. **Easier Maintenance**: Changes to mocks only need updates in one place
4. **Improved Readability**: Tests focus on behavior, not setup
5. **Faster Development**: New tests can leverage existing fixtures
6. **Consistent Patterns**: State creation and mocking follow standard patterns
## Next Steps (Future Work)
From the original `clean-tests.md`, the remaining task is:
### Create Tests for Untested Modules
The following modules still need test coverage:
- `packages/business-buddy-core/src/bb_core/caching/base.py`
- `packages/business-buddy-core/src/bb_core/caching/decorators.py`
- `packages/business-buddy-core/src/bb_core/logging/formatters.py`
- `packages/business-buddy-core/src/bb_core/logging/utils.py`
- `packages/business-buddy-core/src/bb_core/networking/async_utils.py`
- `packages/business-buddy-core/src/bb_core/validation/content.py`
- `packages/business-buddy-extraction/src/bb_extraction/base.py`
- `packages/business-buddy-extraction/src/bb_extraction/entities.py`
- `packages/business-buddy-extraction/src/bb_extraction/quality.py`
- `packages/business-buddy-utils/src/bb_utils/data/costs.py`
- `packages/business-buddy-utils/src/bb_utils/networking/base_client.py`
- `src/biz_bud/nodes/scraping/url_analyzer.py`
- `src/biz_bud/nodes/scraping/url_filters.py`
- `src/biz_bud/services/llm/exceptions.py`
## Test Results
All tests pass with the new structure:
- ✅ 162 tests passing
- ✅ All linters passing (ruff, pyrefly, codespell)
- ✅ Pre-commit hooks passing
- ✅ VS Code test discovery working

View File

@@ -1,96 +0,0 @@
# RAG Agent State Factory Functions
This document describes the RAG agent state factory functions available in `tests/helpers/factories/state_factories.py`.
## Overview
The `StateBuilder` class already includes a comprehensive `with_rag_fields` method that accepts all required RAG agent fields. This method is used by the factory functions to create different RAG agent state scenarios.
## Factory Functions
### 1. `create_rag_agent_state()`
Creates a minimal RAG agent state suitable for most test scenarios.
**Use case**: Basic tests that need a valid RAG agent state without specific requirements.
```python
state = create_rag_agent_state()
# Returns state with:
# - input_url: "https://example.com"
# - force_refresh: False
# - query: "test query"
# - All other fields set to sensible defaults
```
### 2. `create_rag_agent_state_with_existing_content()`
Creates a RAG agent state that simulates finding existing content in the knowledge store.
**Use case**: Testing deduplication logic and scenarios where content already exists.
```python
state = create_rag_agent_state_with_existing_content()
# Returns state with:
# - Existing content metadata
# - should_process: False
# - processing_reason explaining why processing is skipped
# - Complete scrape_params and r2r_params
```
### 3. `create_rag_agent_state_processing()`
Creates a RAG agent state for active processing scenarios.
**Use case**: Testing the processing workflow when content needs to be fetched and stored.
```python
state = create_rag_agent_state_processing()
# Returns state with:
# - GitHub repository URL
# - force_refresh: True
# - should_process: True
# - File pattern configurations for code repositories
# - rag_status: "processing"
```
### 4. `create_minimal_rag_agent_state(**kwargs)`
Creates a minimal RAG agent state with custom field overrides.
**Use case**: Tests that need specific field values without creating a new factory function.
```python
state = create_minimal_rag_agent_state(
force_refresh=True,
content_age_days=10,
rag_status="error"
)
```
## StateBuilder Methods
The `StateBuilder` class provides a fluent interface for building states:
### `with_rag_fields()`
Adds all RAG-specific fields to the state with the following parameters:
- `input_url`: URL to process
- `force_refresh`: Whether to force reprocessing
- `query`: User query/intent
- `url_hash`: SHA256 hash for deduplication (optional)
- `existing_content`: Existing content metadata (optional)
- `content_age_days`: Age of existing content (optional)
- `should_process`: Processing decision
- `processing_reason`: Human-readable reason (optional)
- `scrape_params`: Web scraping parameters (optional)
- `r2r_params`: R2R upload parameters (optional)
- `processing_result`: Processing result (optional)
- `rag_status`: Current status
- `error`: Error message (optional)
## Implementation Details
All factory functions:
1. Use the `StateBuilder` pattern for consistency
2. Include proper configuration in the `config` field
3. Provide sensible defaults for all required fields
4. Return properly typed dictionaries that match `RAGAgentState`
The implementation ensures backward compatibility with existing tests while providing flexibility for new test scenarios.

View File

@@ -1,152 +0,0 @@
# Test Suite Overhaul Summary
## Overview
The test suite has been completely overhauled to improve organization, coverage, and maintainability. All tasks from the testing plan have been completed successfully.
## Completed Tasks
### 1. Test Analysis and Mapping ✅
- Analyzed 101 existing test files
- Mapped test coverage to source code modules
- Identified gaps in critical components
### 2. Created Missing Core Tests ✅
- **Error Handling**: `test_error.py` - Comprehensive error handling tests
- **Utilities**: Tests for error helpers, response helpers, service helpers
- **Scraping**: Full coverage for `scrapers.py` module
- **Caching**: NoOpCache implementation tests
### 3. Test File Consolidation ✅
- Removed duplicate database tests
- Consolidated related test functionality
- Eliminated redundant test cases
### 4. Standardized Naming Conventions ✅
- Unit tests: `test_*.py` (removed `test_unit_` prefix)
- Integration tests: `test_*_integration.py`
- E2E tests: `test_*_e2e.py`
- Manual tests: `test_*_manual.py`
- Renamed 45 files to follow conventions
### 5. Implemented Hierarchical Fixture Architecture ✅
Created comprehensive test helpers:
```
tests/helpers/
├── assertions/
│ └── custom_assertions.py
├── factories/
│ └── state_factories.py
├── fixtures/
│ ├── config_fixtures.py
│ └── mock_fixtures.py
└── mocks/
└── mock_builders.py
```
### 6. Added Comprehensive E2E Tests ✅
- **Research Workflow**: Complete E2E scenarios with error handling
- **Analysis Workflow**: Data analysis, visualization, planning tests
- **Menu Intelligence**: Ingredient optimization, cost analysis tests
- **RAG Workflow**: URL processing, GitHub repos, duplicate detection
### 7. Type and Configuration Validation ✅
- **Type Validation**: Tests for all TypedDict definitions
- **Config Validation**: Comprehensive Pydantic model validation
- **Bounds Checking**: Proper constraint validation
### 8. Automated Manual Tests ✅
Converted 5 out of 8 manual tests to automated tests:
- LLM JSON extraction
- LLM streaming
- Qdrant connectivity
- Semantic extraction debugging
Kept 3 manual tests for debugging purposes.
## Test Distribution
### Current Test Count: 104 files
| Category | Count | Percentage |
|----------|-------|------------|
| Unit Tests | 64 | 61.5% |
| Integration Tests | 28 | 26.9% |
| E2E Tests | 5 | 4.8% |
| Manual Tests | 7 | 6.7% |
### Test Coverage by Module
| Module | Coverage | Notes |
|--------|----------|-------|
| Core (input/output/error) | ✅ High | Complete coverage |
| LLM Services | ✅ High | Including streaming |
| Search & Extraction | ✅ High | All providers covered |
| Analysis Nodes | ✅ High | Data, viz, planning |
| Configuration | ✅ High | Full validation |
| Utilities | ✅ High | All helpers tested |
| Graphs/Workflows | ✅ High | E2E scenarios |
## Key Improvements
### 1. Test Organization
- Clear directory structure
- Consistent naming conventions
- Logical grouping of related tests
### 2. Test Quality
- Comprehensive assertions
- Proper mocking strategies
- Realistic test scenarios
- Error case coverage
### 3. Developer Experience
- Reusable fixtures and factories
- Custom assertion helpers
- Mock builders for complex scenarios
- Clear test documentation
### 4. Maintainability
- Standardized patterns
- Minimal duplication
- Clear separation of concerns
- Easy to add new tests
## Best Practices Established
1. **Use StateBuilder** for creating test states
2. **Use MockBuilders** for complex mock scenarios
3. **Use custom assertions** for domain-specific validation
4. **Follow naming conventions** strictly
5. **Group related tests** in appropriate directories
6. **Document test purposes** clearly
7. **Test both success and failure** scenarios
8. **Use fixtures** for common test data
## Running the Tests
```bash
# Run all tests
make test
# Run specific test categories
pytest tests/unit_tests/ -v
pytest tests/integration_tests/ -v
pytest tests/e2e/ -v
# Run with coverage
make test # Already includes coverage
# Run in watch mode
make test_watch
```
## Future Recommendations
1. **Maintain Coverage**: Keep test coverage above 70%
2. **Update Tests**: When adding features, add corresponding tests
3. **Review E2E Tests**: Periodically review E2E scenarios for completeness
4. **Performance Tests**: Consider adding performance benchmarks
5. **Documentation**: Keep test documentation up to date
The test suite is now well-organized, comprehensive, and ready to support continued development of the Business Buddy application.

View File

@@ -120,7 +120,9 @@ def transform_config_to_catalog_format(catalog_config: dict) -> dict:
}
async def analyze_catalog_with_user_query(catalog_data: dict, user_query: str):
async def analyze_catalog_with_user_query(
catalog_data: dict, user_query: str
) -> dict[str, object] | None:
"""Run catalog intelligence analysis with the given query."""
# Create the catalog intelligence graph
graph = create_catalog_intel_graph()

View File

@@ -6,7 +6,7 @@ import asyncio
from bb_utils.core import get_logger
from biz_bud.config.loader import load_config_async
from biz_bud.graphs.url_to_rag import process_url_to_ragflow
from biz_bud.graphs.url_to_r2r import process_url_to_r2r
logger = get_logger(__name__)
@@ -54,7 +54,8 @@ async def test_with_custom_limits():
try:
start_time = asyncio.get_event_loop().time()
result = await process_url_to_ragflow(test["url"], test_config)
url = str(test["url"]) # Ensure type safety
result = await process_url_to_r2r(url, test_config)
elapsed = asyncio.get_event_loop().time() - start_time
logger.info(f"\n✅ Completed in {elapsed:.1f} seconds")

View File

@@ -4,7 +4,7 @@
import asyncio
import os
from biz_bud.nodes.research.ragflow_upload import (
from biz_bud.nodes.rag.upload_r2r import (
extract_meaningful_name_from_url,
)
@@ -52,9 +52,11 @@ async def main():
}
print(f"URL: {example_state['input_url']}")
print(
f"Custom dataset name: {example_state['config']['rag_config']['custom_dataset_name']}"
)
config = example_state["config"]
assert isinstance(config, dict), "Config should be a dictionary"
rag_config = config["rag_config"]
assert isinstance(rag_config, dict), "RAG config should be a dictionary"
print(f"Custom dataset name: {rag_config['custom_dataset_name']}")
print("\nThis custom name will be used instead of the auto-extracted 'langgraph'")
# Note: Actual upload would happen here if RAGFlow is configured

View File

@@ -46,14 +46,17 @@ async def analyze_crawl_vs_scrape():
if scrape_result.success:
print("✅ Direct scrape successful")
print(
f" - Content length: {len(scrape_result.data.markdown or '')} chars"
)
print(f" - Links found: {len(scrape_result.data.links or [])}")
if scrape_result.data.links:
print(" - Sample links:")
for link in scrape_result.data.links[:3]:
print(f"{link}")
if scrape_result.data:
print(
f" - Content length: {len(scrape_result.data.markdown or '')} chars"
)
print(f" - Links found: {len(scrape_result.data.links or [])}")
if scrape_result.data.links:
print(" - Sample links:")
for link in scrape_result.data.links[:3]:
print(f"{link}")
else:
print(" - No data returned")
# Now let's see what crawl does
print(f"\n2. Crawl starting from {url}")
@@ -282,11 +285,21 @@ async def monitor_batch_scrape():
duration = (datetime.now() - start_time).total_seconds()
# Analyze results
successful = sum(1 for r in results if r.success)
failed = len(results) - successful
# Analyze results - filter for valid result objects with proper attributes
valid_results = [
r
for r in results
if hasattr(r, "success") and not isinstance(r, (dict, list, str))
]
successful = sum(1 for r in valid_results if getattr(r, "success", False))
failed = len(valid_results) - successful
total_content = sum(
len(r.data.markdown or "") for r in results if r.success and r.data
len(getattr(r.data, "markdown", "") or "")
for r in valid_results
if getattr(r, "success", False)
and hasattr(r, "data")
and r.data
and hasattr(r.data, "markdown")
)
print("\n=== Batch Complete ===")
@@ -295,17 +308,25 @@ async def monitor_batch_scrape():
f"Success rate: {successful}/{len(results)} ({successful / len(results) * 100:.0f}%)"
)
print(f"Total content: {total_content:,} characters")
print(f"Average time per URL: {duration / len(results):.2f} seconds")
print(f"Average time per URL: {duration / len(urls):.2f} seconds")
# Show individual results
print("\nIndividual Results:")
for url, result in zip(urls, results):
if result.success and result.data:
content_len = len(result.data.markdown or "")
print(f"{url} - {content_len} chars")
else:
error = result.error or "Unknown error"
print(f"{url} - {error}")
for i, url in enumerate(urls):
if i < len(results):
result = results[i]
if (
hasattr(result, "success")
and not isinstance(result, (dict, list, str))
and getattr(result, "success", False)
and hasattr(result, "data")
and result.data
):
content_len = len(getattr(result.data, "markdown", "") or "")
print(f"{url} - {content_len} chars")
else:
error = getattr(result, "error", "Unknown error") or "Unknown error"
print(f"{url} - {error}")
async def monitor_ragflow_dataset_creation():

View File

@@ -5,11 +5,12 @@ for real-time LLM responses in various scenarios.
"""
import asyncio
from typing import Any
from typing import Any, cast
from bb_core import get_service_factory
from langchain_core.messages import AIMessage, HumanMessage
from biz_bud.config.loader import load_config
from biz_bud.config.loader import load_config_async
from biz_bud.services.factory import ServiceFactory
@@ -18,11 +19,11 @@ async def example_basic_streaming():
print("=== Basic Streaming Example ===\n")
# Load configuration
config = await load_config()
config = await load_config_async()
# Create LLM client
async with ServiceFactory(config) as factory:
llm_client = await factory.get_llm_service()
llm_client = await factory.get_llm_client()
# Stream a response
print("Assistant: ", end="", flush=True)
@@ -39,28 +40,28 @@ async def example_with_progress_tracking():
"""Example with progress tracking and UI updates."""
print("=== Streaming with Progress Tracking ===\n")
config = await load_config()
config = await load_config_async()
async with ServiceFactory(config) as factory:
llm_client = await factory.get_llm_service()
llm_client = await factory.get_llm_client()
# Track progress
total_chars = 0
chunk_count = 0
# Track progress using mutable container
progress = {"total_chars": 0, "chunk_count": 0}
def track_progress(chunk: str) -> None:
nonlocal total_chars, chunk_count
total_chars += len(chunk)
chunk_count += 1
progress["total_chars"] += len(chunk)
progress["chunk_count"] += 1
# In a real app, update progress bar here
print(
f"\r[Chunks: {chunk_count}, Chars: {total_chars}]", end="", flush=True
f"\r[Chunks: {progress['chunk_count']}, Chars: {progress['total_chars']}]",
end="",
flush=True,
)
# Get response with progress tracking
response = await llm_client.llm_chat_with_stream_callback(
prompt="Explain the benefits of async programming in 3 paragraphs",
on_chunk_callback=track_progress,
callback_fn=track_progress,
)
print(f"\n\nComplete response ({len(response)} chars):")
@@ -71,7 +72,7 @@ async def example_websocket_integration():
"""Example of streaming to a WebSocket for real-time UI updates."""
print("\n=== WebSocket Streaming Example ===\n")
config = await load_config()
config = await load_config_async()
# Simulate a WebSocket connection
class MockWebSocket:
@@ -81,16 +82,21 @@ async def example_websocket_integration():
websocket = MockWebSocket()
async with ServiceFactory(config) as factory:
llm_client = await factory.get_llm_service()
llm_client = await factory.get_llm_client()
# Stream to WebSocket
chunks_sent = 0
# Stream to WebSocket using mutable container
websocket_state = {"chunks_sent": 0}
async def send_to_websocket(chunk: str) -> None:
nonlocal chunks_sent
chunks_sent += 1
await websocket.send_json(
{"type": "llm_chunk", "content": chunk, "chunk_number": chunks_sent}
def send_to_websocket(chunk: str) -> None:
websocket_state["chunks_sent"] += 1
asyncio.create_task(
websocket.send_json(
{
"type": "llm_chunk",
"content": chunk,
"chunk_number": websocket_state["chunks_sent"],
}
)
)
# Start streaming
@@ -98,13 +104,13 @@ async def example_websocket_integration():
response = await llm_client.llm_chat_with_stream_callback(
prompt="Describe the architecture of a modern web application",
on_chunk_callback=send_to_websocket,
callback_fn=send_to_websocket,
)
await websocket.send_json(
{
"type": "stream_complete",
"total_chunks": chunks_sent,
"total_chunks": websocket_state["chunks_sent"],
"total_length": len(response),
}
)
@@ -114,10 +120,10 @@ async def example_parallel_streaming():
"""Example of streaming from multiple prompts in parallel."""
print("\n=== Parallel Streaming Example ===\n")
config = await load_config()
config = await load_config_async()
async with ServiceFactory(config) as factory:
llm_client = await factory.get_llm_service()
llm_client = await factory.get_llm_client()
prompts = [
"Write a one-line summary of machine learning",
@@ -125,7 +131,7 @@ async def example_parallel_streaming():
"Write a one-line summary of cybersecurity",
]
async def stream_with_prefix(prompt: str, prefix: str) -> None:
async def stream_with_prefix(prompt: str, prefix: str) -> str:
"""Stream a response with a prefix for identification."""
print(f"\n{prefix}: ", end="", flush=True)
@@ -138,7 +144,7 @@ async def example_parallel_streaming():
# Stream all prompts in parallel
tasks = [
stream_with_prefix(prompt, f"Topic {i+1}")
stream_with_prefix(prompt, f"Topic {i + 1}")
for i, prompt in enumerate(prompts)
]
@@ -146,17 +152,17 @@ async def example_parallel_streaming():
print("\n\n=== Summary ===")
for i, response in enumerate(responses):
print(f"Topic {i+1}: {response}")
print(f"Topic {i + 1}: {response}")
async def example_error_handling():
"""Example of handling streaming errors gracefully."""
print("\n=== Error Handling Example ===\n")
config = await load_config()
config = await load_config_async()
async with ServiceFactory(config) as factory:
llm_client = await factory.get_llm_service()
llm_client = await factory.get_llm_client()
# Example with timeout
try:
@@ -189,9 +195,8 @@ async def example_langgraph_node():
) -> dict[str, Any]:
"""Node that performs analysis with streaming output."""
# Get the LLM client from state config
config = state["config"]
factory = ServiceFactory(config)
llm_client = await factory.get_llm_service()
factory = await get_service_factory(cast("dict[str, Any]", state))
llm_client = await factory.get_llm_client()
# Extract the user's request
last_message = state["messages"][-1]
@@ -201,15 +206,14 @@ async def example_langgraph_node():
analysis_chunks = []
async def collect_and_display(chunk: str) -> None:
def collect_and_display(chunk: str) -> None:
analysis_chunks.append(chunk)
print(chunk, end="", flush=True)
# Perform streaming analysis
full_analysis = await llm_client.llm_chat_with_stream_callback(
prompt=f"Analyze this request and provide insights: {last_message.content}",
system_prompt="You are a helpful analyst. Provide clear, structured analysis.",
on_chunk_callback=collect_and_display,
prompt=f"Analyze this request and provide insights: {cast('Any', last_message).content}",
callback_fn=collect_and_display,
)
print("\n")
@@ -227,14 +231,15 @@ async def example_langgraph_node():
# Simulate node execution
print("Simulating LangGraph node execution...")
config = await load_config()
config = await load_config_async()
# Create mock state
mock_state: BusinessBuddyState = {
"messages": [HumanMessage(content="What are the key trends in AI for 2024?")],
"config": config,
"config": cast("Any", {"enabled": True}), # Use minimal config for example
"errors": [],
"status": "processing",
"status": "running",
"thread_id": "example-thread-123",
}
# Execute the node

View File

@@ -52,11 +52,14 @@ async def main():
result = await process_url_to_r2r(test_url, config)
if result.get("r2r_info"):
r2r_info = result["r2r_info"]
r2r_info = result.get("r2r_info")
print("\nUpload successful!")
print(f"- Document ID: {r2r_info.get('document_id')}")
print(f"- Collection: {r2r_info.get('collection_name')}")
print(f"- Title: {r2r_info.get('title')}")
if r2r_info:
print(f"- Document ID: {r2r_info.get('document_id')}")
print(f"- Collection: {r2r_info.get('collection_name')}")
print(f"- Title: {r2r_info.get('title')}")
else:
print("- No R2R info available")
else:
print(f"\nUpload failed: {result.get('error', 'Unknown error')}")
except Exception as e:

View File

@@ -0,0 +1,123 @@
"""Example demonstrating the new succinct logging configuration.
This shows how the new logging filters reduce verbosity while maintaining
meaningful information in the logs.
"""
import logging
import time
from pathlib import Path
# Import the logging configuration
from bb_utils.core.log_config import (
configure_global_logging,
info_highlight,
load_logging_config_from_yaml,
setup_logger,
warning_highlight,
)
def simulate_verbose_logs():
"""Simulate the verbose logs that were problematic."""
logger = setup_logger("example_app", level=logging.DEBUG)
# Simulate LangGraph queue stats (these will be filtered)
for i in range(20):
logger.info(
f"Stats(queue_id='test-queue-{i}', worker_id='worker-1', "
f"timestamp=datetime.datetime(2025, 1, 9, 10, 30, {i}, 123456))"
)
# Simulate HTTP retries (only first and last will show)
for attempt in range(1, 6):
logger.warning(f"Retrying HTTP request: attempt {attempt}/5")
time.sleep(0.1)
# Simulate timeout warnings (will be reduced)
for i in range(10):
logger.warning(f"Request timeout after 30 seconds - iteration {i}")
# Simulate service initialization (only first will show)
for service in ["auth", "database", "cache", "api"]:
logger.info(f"Initializing {service} service...")
logger.info(f"Service {service} initialized successfully")
def demonstrate_new_features():
"""Demonstrate the new logging features."""
logger = setup_logger("demo", level=logging.INFO)
info_highlight("Starting demonstration of succinct logging", category="DEMO")
# The new filters will:
# 1. Show only every 10th queue stat
# 2. Show only first and last retry
# 3. Show timeout warnings every 5th occurrence
# 4. Show service init only once per service
# 5. Simplify datetime formatting
simulate_verbose_logs()
info_highlight("Verbose logs have been filtered for clarity", category="DEMO")
def demonstrate_yaml_config():
"""Show how to use YAML configuration for fine-grained control."""
# Try to load YAML config if available
config_path = (
Path(__file__).parent.parent
/ "packages"
/ "business-buddy-utils"
/ "src"
/ "bb_utils"
/ "core"
/ "logging_config.yaml"
)
if config_path.exists():
try:
load_logging_config_from_yaml(str(config_path))
info_highlight("Loaded YAML logging configuration", category="CONFIG")
except Exception as e:
warning_highlight(f"Could not load YAML config: {e}", category="CONFIG")
# Now logs will follow the YAML configuration rules
def demonstrate_custom_log_levels():
"""Show how to adjust logging levels programmatically."""
# Set different levels for different components
configure_global_logging(
root_level=logging.INFO,
third_party_level=logging.WARNING, # Reduces third-party noise
)
# You can also set specific loggers
langgraph_logger = logging.getLogger("langgraph")
langgraph_logger.setLevel(logging.ERROR) # Only show errors from LangGraph
httpx_logger = logging.getLogger("httpx")
httpx_logger.setLevel(logging.ERROR) # Only show errors from httpx
if __name__ == "__main__":
print("=== Succinct Logging Configuration Demo ===\n")
print("1. Demonstrating filtered verbose logs:")
demonstrate_new_features()
print("\n2. Demonstrating YAML configuration:")
demonstrate_yaml_config()
print("\n3. Demonstrating custom log levels:")
demonstrate_custom_log_levels()
print("\n=== Demo Complete ===")
print("\nKey improvements:")
print("- LangGraph queue stats: Shown every 10th occurrence")
print("- HTTP retries: Only first and last attempts shown")
print("- Timeout warnings: Shown every 5th occurrence")
print("- Service init messages: Shown only once")
print("- Datetime objects: Simplified to readable format")
print("- HTTP client logs: Reduced to WARNING level by default")

View File

@@ -7,7 +7,8 @@
"catalog_intel": "./src/biz_bud/graphs/catalog_intel.py:catalog_intel_factory",
"catalog_research": "./src/biz_bud/graphs/catalog_research.py:catalog_research_factory",
"url_to_r2r": "./src/biz_bud/graphs/url_to_r2r.py:url_to_r2r_graph_factory",
"rag_agent": "./src/biz_bud/agents/rag_agent.py:create_rag_agent_for_api"
"rag_agent": "./src/biz_bud/agents/rag_agent.py:create_rag_agent_for_api",
"error_handling": "./src/biz_bud/graphs/error_handling.py:error_handling_graph_factory"
},
"env": ".env"
}

View File

@@ -142,3 +142,10 @@ ignore_missing_imports = True
[mypy-graphviz.*]
ignore_missing_imports = True
# Allow explicit Any for legitimate JSON processing use cases
[mypy-packages.business-buddy-core.src.bb_core.validation.merge]
disallow_any_explicit = False
[mypy-bb_core.validation.merge]
disallow_any_explicit = False

8072
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"task-master-ai": "^0.19.0"
}
}

View File

@@ -15,6 +15,12 @@ dependencies = [
"pyyaml>=6.0.2",
"typing-extensions>=4.13.2,<4.14.0",
"pydantic>=2.10.0,<2.11",
"business-buddy-utils @ {root:uri}/../business-buddy-utils",
"requests>=2.32.4",
"nltk>=3.9.1",
"tiktoken>=0.8.0",
"docling>=2.8.3",
"python-dateutil>=2.9.0",
]
[project.optional-dependencies]
@@ -40,6 +46,9 @@ line-length = 88
[tool.ruff.lint]
select = ["E", "F", "UP", "B", "SIM", "I"]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/bb_core"]

View File

@@ -0,0 +1,58 @@
# Pyrefly configuration for business-buddy-core package
# Include source directories
project_includes = [
"src/bb_core",
"tests"
]
# Exclude directories
project_excludes = [
"build/",
"dist/",
".venv/",
"venv/",
"**/__pycache__/",
"**/htmlcov/",
"**/.pytest_cache/",
"**/.mypy_cache/",
"**/.ruff_cache/",
"**/*.egg-info/"
]
# Search paths for module resolution
search_path = [
"src",
"tests"
]
# Python version
python_version = "3.12.0"
# Libraries to ignore missing imports
replace_imports_with_any = [
"pytest",
"pytest.*",
"bb_utils.*",
"nltk.*",
"tiktoken.*",
"requests",
"requests.*",
"docling.*",
"aiohttp",
"aiohttp.*",
"dateutil",
"dateutil.*",
"redis.*",
"rich.*",
"pyyaml.*",
"pydantic.*",
"aiofiles.*",
"langgraph.*",
"langchain_core.*",
"biz_bud.*"
]
# Allow explicit Any for specific JSON processing modules
# Note: per_module_overrides is not supported in pyrefly
# This module uses Any for legitimate JSON processing use cases

View File

@@ -1,3 +1,11 @@
"""Business Buddy Core - Foundation utilities for the BizBud framework."""
__version__ = "0.1.0"
# Service helpers
from bb_core.service_helpers import get_service_factory, get_service_factory_sync
__all__ = [
"get_service_factory",
"get_service_factory_sync",
]

View File

@@ -16,8 +16,8 @@ T = TypeVar("T")
def _generate_cache_key(
func_name: str,
args: tuple,
kwargs: dict,
args: tuple[object, ...],
kwargs: dict[str, object],
prefix: str = "",
) -> str:
"""Generate a cache key from function name and arguments.
@@ -83,14 +83,17 @@ def cache_async(
# Generate cache key
if key_func:
# Cast to avoid pyrefly ParamSpec issues
cache_key = key_func(*cast(tuple, args), **cast(dict, kwargs))
cache_key = key_func(
*cast("tuple[object, ...]", args),
**cast("dict[str, object]", kwargs),
)
else:
# Convert ParamSpec args/kwargs to tuple/dict for cache key generation
# Cast to avoid pyrefly ParamSpec issues
cache_key = _generate_cache_key(
func.__name__,
cast(tuple, args),
cast(dict, kwargs),
cast("tuple[object, ...]", args),
cast("dict[str, object]", kwargs),
key_prefix,
)
@@ -124,14 +127,17 @@ def cache_async(
"""Delete specific cache entry."""
if key_func:
# Cast to avoid pyrefly ParamSpec issues
cache_key = key_func(*cast(tuple, args), **cast(dict, kwargs))
cache_key = key_func(
*cast("tuple[object, ...]", args),
**cast("dict[str, object]", kwargs),
)
else:
# Convert ParamSpec args/kwargs to tuple/dict for cache key generation
# Cast to avoid pyrefly ParamSpec issues
cache_key = _generate_cache_key(
func.__name__,
cast(tuple, args),
cast(dict, kwargs),
cast("tuple[object, ...]", args),
cast("dict[str, object]", kwargs),
key_prefix,
)
await backend.delete(cache_key)
@@ -174,14 +180,17 @@ def cache_sync(
# Generate cache key
if key_func:
# Cast to avoid pyrefly ParamSpec issues
cache_key = key_func(*cast(tuple, args), **cast(dict, kwargs))
cache_key = key_func(
*cast("tuple[object, ...]", args),
**cast("dict[str, object]", kwargs),
)
else:
# Convert ParamSpec args/kwargs to tuple/dict for cache key generation
# Cast to avoid pyrefly ParamSpec issues
cache_key = _generate_cache_key(
func.__name__,
cast(tuple, args),
cast(dict, kwargs),
cast("tuple[object, ...]", args),
cast("dict[str, object]", kwargs),
key_prefix,
)

View File

@@ -0,0 +1,58 @@
"""LangGraph patterns and utilities for Business Buddy.
This module provides comprehensive utilities for implementing LangGraph
best practices including state immutability, cross-cutting concerns,
configuration management, and graph orchestration.
"""
from .cross_cutting import (
handle_errors,
log_node_execution,
retry_on_failure,
standard_node,
track_metrics,
)
from .graph_config import (
configure_graph_with_injection,
create_config_injected_node,
extract_config_from_state,
update_node_to_use_config,
)
from .runnable_config import (
ConfigurationProvider,
create_runnable_config,
)
from .state_immutability import (
ImmutableDict,
ImmutableStateError,
StateUpdater,
create_immutable_state,
ensure_immutable_node,
update_state_immutably,
validate_state_schema,
)
__all__ = [
# State immutability
"ImmutableDict",
"ImmutableStateError",
"StateUpdater",
"create_immutable_state",
"ensure_immutable_node",
"update_state_immutably",
"validate_state_schema",
# Cross-cutting concerns
"handle_errors",
"log_node_execution",
"retry_on_failure",
"standard_node",
"track_metrics",
# Configuration management
"ConfigurationProvider",
"create_runnable_config",
# Graph configuration
"configure_graph_with_injection",
"create_config_injected_node",
"extract_config_from_state",
"update_node_to_use_config",
]

View File

@@ -0,0 +1,584 @@
"""Cross-cutting concerns for LangGraph nodes and tools.
This module provides decorators and utilities for handling cross-cutting
concerns like logging, metrics, error handling, and caching across all
nodes and tools in the Business Buddy framework.
"""
import asyncio
import functools
import time
from collections.abc import Callable
from datetime import datetime
from typing import Any, TypedDict, cast
from bb_utils.core import get_logger
logger = get_logger(__name__)
class NodeMetric(TypedDict):
"""Type definition for node metrics."""
count: int
success_count: int
failure_count: int
total_duration_ms: float
avg_duration_ms: float
last_execution: str | None
last_error: str | None
def log_node_execution(
node_name: str | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to log node execution with timing and context.
This decorator automatically logs entry, exit, and timing information
for node functions, including extracting context from RunnableConfig.
Args:
node_name: Optional explicit node name (defaults to function name)
Returns:
Decorated function with logging
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
actual_node_name = node_name or func.__name__
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
start_time = time.time()
# Extract context from RunnableConfig if available
context = _extract_context_from_args(args, kwargs)
logger.info(
f"Node '{actual_node_name}' started",
extra={
"node_name": actual_node_name,
"run_id": context.get("run_id"),
"user_id": context.get("user_id"),
},
)
try:
result = await func(*args, **kwargs)
elapsed_ms = (time.time() - start_time) * 1000
logger.info(
f"Node '{actual_node_name}' completed successfully",
extra={
"node_name": actual_node_name,
"duration_ms": elapsed_ms,
"run_id": context.get("run_id"),
},
)
return result
except Exception as e:
elapsed_ms = (time.time() - start_time) * 1000
logger.error(
f"Node '{actual_node_name}' failed: {str(e)}",
extra={
"node_name": actual_node_name,
"duration_ms": elapsed_ms,
"error_type": type(e).__name__,
"run_id": context.get("run_id"),
},
)
raise
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
start_time = time.time()
context = _extract_context_from_args(args, kwargs)
logger.info(
f"Node '{actual_node_name}' started",
extra={
"node_name": actual_node_name,
"run_id": context.get("run_id"),
"user_id": context.get("user_id"),
},
)
try:
result = func(*args, **kwargs)
elapsed_ms = (time.time() - start_time) * 1000
logger.info(
f"Node '{actual_node_name}' completed successfully",
extra={
"node_name": actual_node_name,
"duration_ms": elapsed_ms,
"run_id": context.get("run_id"),
},
)
return result
except Exception as e:
elapsed_ms = (time.time() - start_time) * 1000
logger.error(
f"Node '{actual_node_name}' failed: {str(e)}",
extra={
"node_name": actual_node_name,
"duration_ms": elapsed_ms,
"error_type": type(e).__name__,
"run_id": context.get("run_id"),
},
)
raise
# Return appropriate wrapper based on function type
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
def track_metrics(
metric_name: str,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to track metrics for node execution.
This decorator updates state with performance metrics including
execution count, timing, and success/failure rates.
Args:
metric_name: Name of the metric to track
Returns:
Decorated function with metric tracking
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
start_time = time.time()
# Get state from args (first argument is usually state)
state = args[0] if args and isinstance(args[0], dict) else None
metric: NodeMetric | None = None
# Initialize metrics in state if not present
if state is not None:
if "metrics" not in state:
state["metrics"] = {}
metrics = state["metrics"]
# Initialize metric tracking
if metric_name not in metrics:
metrics[metric_name] = NodeMetric(
count=0,
success_count=0,
failure_count=0,
total_duration_ms=0.0,
avg_duration_ms=0.0,
last_execution=None,
last_error=None,
)
metric = cast("NodeMetric", metrics[metric_name])
if metric is not None:
metric["count"] = (metric["count"] or 0) + 1
try:
result = await func(*args, **kwargs)
# Update success metrics
if state is not None and metric is not None:
elapsed_ms = (time.time() - start_time) * 1000
metric["success_count"] = (metric["success_count"] or 0) + 1
metric["total_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) + elapsed_ms
count = metric["count"] or 1
metric["avg_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) / count
metric["last_execution"] = datetime.utcnow().isoformat()
return result
except Exception as e:
# Update failure metrics
if state is not None and metric is not None:
elapsed_ms = (time.time() - start_time) * 1000
metric["failure_count"] = (metric["failure_count"] or 0) + 1
metric["total_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) + elapsed_ms
count = metric["count"] or 1
metric["avg_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) / count
metric["last_execution"] = datetime.utcnow().isoformat()
metric["last_error"] = str(e)
raise
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
# Similar implementation for sync functions
start_time = time.time()
state = args[0] if args and isinstance(args[0], dict) else None
metric: NodeMetric | None = None
if state is not None:
if "metrics" not in state:
state["metrics"] = {}
metrics = state["metrics"]
if metric_name not in metrics:
metrics[metric_name] = NodeMetric(
count=0,
success_count=0,
failure_count=0,
total_duration_ms=0.0,
avg_duration_ms=0.0,
last_execution=None,
last_error=None,
)
metric = cast("NodeMetric", metrics[metric_name])
if metric is not None:
metric["count"] = (metric["count"] or 0) + 1
try:
result = func(*args, **kwargs)
if state is not None and metric is not None:
elapsed_ms = (time.time() - start_time) * 1000
metric["success_count"] = (metric["success_count"] or 0) + 1
metric["total_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) + elapsed_ms
count = metric["count"] or 1
metric["avg_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) / count
metric["last_execution"] = datetime.utcnow().isoformat()
return result
except Exception as e:
if state is not None and metric is not None:
elapsed_ms = (time.time() - start_time) * 1000
metric["failure_count"] = (metric["failure_count"] or 0) + 1
metric["total_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) + elapsed_ms
count = metric["count"] or 1
metric["avg_duration_ms"] = (
metric["total_duration_ms"] or 0.0
) / count
metric["last_execution"] = datetime.utcnow().isoformat()
metric["last_error"] = str(e)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
def handle_errors(
error_handler: Callable[[Exception], Any] | None = None, fallback_value: Any = None
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator for standardized error handling in nodes.
This decorator provides consistent error handling with optional
custom error handlers and fallback values.
Args:
error_handler: Optional custom error handler function
fallback_value: Value to return on error (if not re-raising)
Returns:
Decorated function with error handling
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except Exception as e:
# Log the error
logger.error(
f"Error in {func.__name__}: {str(e)}",
exc_info=True,
extra={"function": func.__name__, "error_type": type(e).__name__},
)
# Call custom error handler if provided
if error_handler:
error_handler(e)
# Update state with error if available
state = args[0] if args and isinstance(args[0], dict) else None
if state and "errors" in state:
if not isinstance(state["errors"], list):
state["errors"] = []
state["errors"].append(
{
"node": func.__name__,
"error": str(e),
"type": type(e).__name__,
"timestamp": datetime.utcnow().isoformat(),
}
)
# Return fallback value or re-raise
if fallback_value is not None:
return fallback_value
else:
raise
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except Exception as e:
logger.error(
f"Error in {func.__name__}: {str(e)}",
exc_info=True,
extra={"function": func.__name__, "error_type": type(e).__name__},
)
if error_handler:
error_handler(e)
state = args[0] if args and isinstance(args[0], dict) else None
if state and "errors" in state:
if not isinstance(state["errors"], list):
state["errors"] = []
state["errors"].append(
{
"node": func.__name__,
"error": str(e),
"type": type(e).__name__,
"timestamp": datetime.utcnow().isoformat(),
}
)
if fallback_value is not None:
return fallback_value
else:
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
def retry_on_failure(
max_attempts: int = 3,
backoff_seconds: float = 1.0,
exponential_backoff: bool = True,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to retry node execution on failure.
Args:
max_attempts: Maximum number of retry attempts
backoff_seconds: Initial backoff time between retries
exponential_backoff: Whether to use exponential backoff
Returns:
Decorated function with retry logic
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
last_exception = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
wait_time = backoff_seconds
if exponential_backoff:
wait_time *= 2**attempt
logger.warning(
f"Attempt {attempt + 1}/{max_attempts} failed for "
f"{func.__name__}, retrying in {wait_time}s: {str(e)}"
)
await asyncio.sleep(wait_time)
else:
logger.error(
f"All {max_attempts} attempts failed for "
f"{func.__name__}: {str(e)}"
)
if last_exception:
raise last_exception
else:
raise RuntimeError(
f"Unexpected error in retry logic for {func.__name__}"
)
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
wait_time = backoff_seconds
if exponential_backoff:
wait_time *= 2**attempt
logger.warning(
f"Attempt {attempt + 1}/{max_attempts} failed for "
f"{func.__name__}, retrying in {wait_time}s: {str(e)}"
)
time.sleep(wait_time)
else:
logger.error(
f"All {max_attempts} attempts failed for "
f"{func.__name__}: {str(e)}"
)
if last_exception:
raise last_exception
else:
raise RuntimeError(
f"Unexpected error in retry logic for {func.__name__}"
)
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
def _extract_context_from_args(
args: tuple[Any, ...], kwargs: dict[str, Any]
) -> dict[str, Any]:
"""Extract context information from function arguments.
Looks for RunnableConfig in args/kwargs and extracts relevant context.
Args:
args: Function positional arguments
kwargs: Function keyword arguments
Returns:
Dictionary with extracted context (run_id, user_id, etc.)
"""
context = {}
# Check if we can extract RunnableConfig-like data
try:
# Check kwargs for config
if "config" in kwargs:
config = kwargs["config"]
# Check if config looks like a RunnableConfig (duck typing for TypedDict)
if (
isinstance(config, dict)
and any(
key in config
for key in [
"tags",
"metadata",
"callbacks",
"run_name",
"configurable",
]
)
and "metadata" in config
and isinstance(config["metadata"], dict)
):
context.update(config["metadata"])
# Check args for RunnableConfig
for arg in args:
# Check if arg looks like a RunnableConfig (duck typing for TypedDict)
if isinstance(arg, dict) and any(
key in arg
for key in ["tags", "metadata", "callbacks", "run_name", "configurable"]
):
if "metadata" in arg and isinstance(arg["metadata"], dict):
context.update(arg["metadata"])
break
except ImportError:
# LangChain not available, skip RunnableConfig extraction
pass
return context
# Composite decorator for common node patterns
def standard_node(
node_name: str | None = None,
metric_name: str | None = None,
retry_attempts: int = 0,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Composite decorator applying standard cross-cutting concerns.
This decorator combines logging, metrics, error handling, and retries
into a single convenient decorator for standard nodes.
Args:
node_name: Optional explicit node name
metric_name: Optional metric name (defaults to node name)
retry_attempts: Number of retry attempts (0 = no retries)
Returns:
Decorated function with all standard concerns applied
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# Apply decorators in order (innermost to outermost)
decorated = func
# Add retry if requested
if retry_attempts > 0:
decorated = retry_on_failure(max_attempts=retry_attempts)(decorated)
# Add error handling
decorated = handle_errors()(decorated)
# Add metrics tracking
if metric_name or node_name:
decorated = track_metrics(metric_name or node_name or func.__name__)(
decorated
)
# Add logging
decorated = log_node_execution(node_name)(decorated)
return decorated
return decorator

View File

@@ -0,0 +1,178 @@
"""Graph configuration utilities for LangGraph integration.
This module provides utilities for configuring graphs with RunnableConfig,
enabling consistent configuration injection across all nodes and tools.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph
from .runnable_config import ConfigurationProvider, create_runnable_config
if TYPE_CHECKING:
pass
def configure_graph_with_injection(
graph_builder: StateGraph,
app_config: Any,
service_factory: Any | None = None,
**config_overrides: Any,
) -> StateGraph:
"""Configure a graph builder with dependency injection.
This function wraps all nodes in the graph to automatically inject
RunnableConfig with the application configuration and service factory.
Args:
graph_builder: The StateGraph builder to configure.
app_config: The application configuration.
service_factory: Optional service factory for dependency injection.
**config_overrides: Additional configuration overrides.
Returns:
The configured StateGraph builder.
"""
# Create base RunnableConfig
base_config = create_runnable_config(
app_config=app_config, service_factory=service_factory, **config_overrides
)
# Get all nodes that have been added
nodes = graph_builder.nodes
# Wrap each node to inject configuration
for node_name, node_func in nodes.items():
# Skip if already configured
if hasattr(node_func, "_runnable_config_injected"):
continue
# Create wrapper that injects config
wrapped_node = create_config_injected_node(node_func, base_config)
# Replace the node
graph_builder.nodes[node_name] = wrapped_node
return graph_builder
def create_config_injected_node(
node_func: Callable[..., object], base_config: RunnableConfig
) -> Callable[..., object]:
"""Create a node wrapper that injects RunnableConfig.
Args:
node_func: The original node function.
base_config: The base RunnableConfig to inject.
Returns:
A wrapped node function with config injection.
"""
# Check if node expects config parameter
import inspect
from functools import wraps
from langchain_core.runnables import RunnableLambda
sig = inspect.signature(node_func)
expects_config = "config" in sig.parameters
if expects_config:
# Node already expects config, wrap to provide it
@wraps(node_func)
async def config_aware_wrapper(
state: dict, config: RunnableConfig | None = None
) -> Any:
# Merge base config with runtime config
if config:
provider = ConfigurationProvider(base_config)
merged_provider = provider.merge_with(config)
merged_config = merged_provider.to_runnable_config()
else:
merged_config = base_config
# Call original node with merged config
if inspect.iscoroutinefunction(node_func):
return await node_func(state, config=merged_config)
else:
return node_func(state, config=merged_config)
wrapped = RunnableLambda(config_aware_wrapper).with_config(base_config)
else:
# Node doesn't expect config, just wrap with config
wrapped = RunnableLambda(node_func).with_config(base_config)
# Mark as injected to avoid double wrapping
wrapped._runnable_config_injected = True
return wrapped
def update_node_to_use_config(
node_func: Callable[..., object],
) -> Callable[..., object]:
"""Decorator to update a node function to accept and use RunnableConfig.
This decorator modifies node functions to accept a config parameter
and provides access to ConfigurationProvider.
Example:
```python
@update_node_to_use_config
async def my_node(state: dict, config: RunnableConfig) -> dict:
provider = ConfigurationProvider(config)
app_config = provider.get_app_config()
# Use configuration...
return state
```
"""
import inspect
from functools import wraps
sig = inspect.signature(node_func)
# Check if already accepts config
if "config" in sig.parameters:
return node_func
@wraps(node_func)
async def wrapper(
state: dict[str, object], config: RunnableConfig | None = None
) -> object:
# Call original without config (for backward compatibility)
if inspect.iscoroutinefunction(node_func):
return await node_func(state)
else:
return node_func(state)
return wrapper
def extract_config_from_state(state: dict[str, object]) -> object | None:
"""Extract AppConfig from state for backward compatibility.
Args:
state: The graph state dictionary.
Returns:
The AppConfig if found in state, None otherwise.
"""
# Check common locations
if "config" in state and isinstance(state["config"], dict):
# Try to reconstruct AppConfig from dict
try:
# Import would need to be dynamic to avoid circular dependencies
# This is a placeholder implementation
return state["config"]
except Exception:
pass
if "app_config" in state:
return state["app_config"]
return None

View File

@@ -0,0 +1,292 @@
"""RunnableConfig utilities for LangGraph integration.
This module provides utilities for working with LangChain's RunnableConfig
to enable configuration injection and dependency management in LangGraph workflows.
"""
from typing import TYPE_CHECKING, Any, TypeVar, cast
from langchain_core.runnables import RunnableConfig
if TYPE_CHECKING:
pass
T = TypeVar("T")
class ConfigurationProvider:
"""Type-safe configuration provider for LangGraph integration.
This class wraps RunnableConfig to provide a clean API for accessing
configuration values, service instances, and metadata in a type-safe manner.
"""
def __init__(self, config: RunnableConfig | None = None):
"""Initialize the configuration provider.
Args:
config: The RunnableConfig to wrap. If None, uses empty config.
"""
self._config = config or RunnableConfig()
def get_metadata(self, key: str, default: Any = None) -> Any:
"""Get metadata value from the configuration.
Args:
key: The metadata key to retrieve.
default: Default value if key not found.
Returns:
The metadata value or default.
"""
metadata = getattr(self._config, "metadata", {})
return metadata.get(key, default)
def get_run_id(self) -> str | None:
"""Get the current run ID from configuration.
Returns:
The run ID if available, None otherwise.
"""
run_id = self.get_metadata("run_id")
return run_id if isinstance(run_id, str) else None
def get_user_id(self) -> str | None:
"""Get the current user ID from configuration.
Returns:
The user ID if available, None otherwise.
"""
user_id = self.get_metadata("user_id")
return user_id if isinstance(user_id, str) else None
def get_session_id(self) -> str | None:
"""Get the current session ID from configuration.
Returns:
The session ID if available, None otherwise.
"""
session_id = self.get_metadata("session_id")
return session_id if isinstance(session_id, str) else None
def get_app_config(self) -> Any | None:
"""Get the application configuration object.
Returns:
The app configuration if available, None otherwise.
"""
configurable = getattr(self._config, "configurable", {})
return configurable.get("app_config")
def get_service_factory(self) -> Any | None:
"""Get the service factory instance.
Returns:
The service factory if available, None otherwise.
"""
configurable = getattr(self._config, "configurable", {})
return configurable.get("service_factory")
def get_llm_profile(self) -> str:
"""Get the LLM profile to use.
Returns:
The LLM profile name, defaults to "large".
"""
configurable = getattr(self._config, "configurable", {})
profile = configurable.get("llm_profile_override", "large")
return profile if isinstance(profile, str) else "large"
def get_temperature_override(self) -> float | None:
"""Get temperature override for LLM calls.
Returns:
The temperature override if set, None otherwise.
"""
configurable = getattr(self._config, "configurable", {})
value = configurable.get("temperature_override")
return float(value) if isinstance(value, int | float) else None
def get_max_tokens_override(self) -> int | None:
"""Get max tokens override for LLM calls.
Returns:
The max tokens override if set, None otherwise.
"""
configurable = getattr(self._config, "configurable", {})
value = configurable.get("max_tokens_override")
return int(value) if isinstance(value, int | float) else None
def is_streaming_enabled(self) -> bool:
"""Check if streaming is enabled.
Returns:
True if streaming is enabled, False otherwise.
"""
configurable = getattr(self._config, "configurable", {})
value = configurable.get("streaming_enabled", False)
return bool(value)
def is_metrics_enabled(self) -> bool:
"""Check if metrics collection is enabled.
Returns:
True if metrics are enabled, False otherwise.
"""
configurable = getattr(self._config, "configurable", {})
value = configurable.get("metrics_enabled", True)
return bool(value)
def get_custom_value(self, key: str, default: T | None = None) -> T | None:
"""Get a custom configuration value.
Args:
key: The configuration key.
default: Default value if key not found.
Returns:
The configuration value or default.
"""
configurable = getattr(self._config, "configurable", {})
result = configurable.get(key, default)
return cast("T", result) if result is not None else default
def merge_with(self, other: RunnableConfig) -> "ConfigurationProvider":
"""Create a new provider by merging with another config.
Args:
other: The config to merge with.
Returns:
A new ConfigurationProvider with merged config.
"""
# Create new config with merged values
merged = RunnableConfig()
# Copy metadata
self_metadata = getattr(self._config, "metadata", {})
other_metadata = getattr(other, "metadata", {})
merged.metadata = {**self_metadata, **other_metadata}
# Copy configurable
self_configurable = getattr(self._config, "configurable", {})
other_configurable = getattr(other, "configurable", {})
merged.configurable = {**self_configurable, **other_configurable}
# Copy other attributes
for attr in ["tags", "callbacks", "recursion_limit"]:
if hasattr(other, attr):
setattr(merged, attr, getattr(other, attr))
elif hasattr(self._config, attr):
setattr(merged, attr, getattr(self._config, attr))
return ConfigurationProvider(merged)
def to_runnable_config(self) -> RunnableConfig:
"""Convert back to RunnableConfig.
Returns:
The underlying RunnableConfig.
"""
return self._config
@classmethod
def from_app_config(
cls, app_config: Any, service_factory: Any | None = None, **metadata: Any
) -> "ConfigurationProvider":
"""Create a provider from app configuration.
Args:
app_config: The application configuration object.
service_factory: Optional service factory instance.
**metadata: Additional metadata to include.
Returns:
A new ConfigurationProvider instance.
"""
config = RunnableConfig()
# Set configurable values
config.configurable = {
"app_config": app_config,
"service_factory": service_factory,
}
# Set metadata
config.metadata = metadata
return cls(config)
def create_runnable_config(
app_config: Any | None = None,
service_factory: Any | None = None,
llm_profile_override: str | None = None,
temperature_override: float | None = None,
max_tokens_override: int | None = None,
streaming_enabled: bool = False,
metrics_enabled: bool = True,
run_id: str | None = None,
user_id: str | None = None,
session_id: str | None = None,
**custom_values: Any,
) -> RunnableConfig:
"""Create a RunnableConfig with common settings.
Args:
app_config: Application configuration object.
service_factory: Service factory instance.
llm_profile_override: Override for LLM profile selection.
temperature_override: Override for LLM temperature.
max_tokens_override: Override for max tokens.
streaming_enabled: Whether to enable streaming.
metrics_enabled: Whether to enable metrics collection.
run_id: Current run identifier.
user_id: Current user identifier.
session_id: Current session identifier.
**custom_values: Additional custom configuration values.
Returns:
Configured RunnableConfig instance.
"""
config = RunnableConfig()
# Set configurable values
configurable = {
"metrics_enabled": metrics_enabled,
"streaming_enabled": streaming_enabled,
**custom_values,
}
if app_config is not None:
configurable["app_config"] = app_config
if service_factory is not None:
configurable["service_factory"] = service_factory
if llm_profile_override is not None:
configurable["llm_profile_override"] = llm_profile_override
if temperature_override is not None:
configurable["temperature_override"] = temperature_override
if max_tokens_override is not None:
configurable["max_tokens_override"] = max_tokens_override
config.configurable = configurable
# Set metadata
metadata = {}
if run_id is not None:
metadata["run_id"] = run_id
if user_id is not None:
metadata["user_id"] = user_id
if session_id is not None:
metadata["session_id"] = session_id
config.metadata = metadata
return config

View File

@@ -0,0 +1,460 @@
"""State immutability utilities for LangGraph workflows.
This module provides utilities and patterns for ensuring state immutability
in LangGraph nodes, preventing accidental state mutations and ensuring
predictable state transitions.
"""
from __future__ import annotations
import copy
from collections.abc import Callable
from typing import (
Any,
TypeVar,
cast,
)
from typing_extensions import ParamSpec
P = ParamSpec("P")
T = TypeVar("T")
CallableT = TypeVar("CallableT", bound=Callable[..., object])
class ImmutableStateError(Exception):
"""Raised when attempting to mutate an immutable state."""
pass
class ImmutableDict:
"""An immutable dictionary that prevents modifications.
This class wraps a regular dict but prevents any modifications,
raising ImmutableStateError on mutation attempts.
"""
_data: dict[str, Any]
_frozen: bool
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._data = dict(*args, **kwargs)
self._frozen = True
def _check_frozen(self) -> None:
if self._frozen:
raise ImmutableStateError(
"Cannot modify immutable state. Create a new state object instead."
)
# Read-only dict interface
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __contains__(self, key: object) -> bool:
return key in self._data
def __iter__(self) -> Any:
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
def __repr__(self) -> str:
return f"ImmutableDict({self._data!r})"
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def keys(self) -> Any:
return self._data.keys()
def values(self) -> Any:
return self._data.values()
def items(self) -> Any:
return self._data.items()
def copy(self) -> dict[str, Any]:
return self._data.copy()
# Mutation methods that raise errors
def __setitem__(self, key: str, value: Any) -> None:
self._check_frozen()
def __delitem__(self, key: str) -> None:
self._check_frozen()
def pop(self, key: str, default: Any = None) -> Any:
self._check_frozen()
return None # Never reached
def popitem(self) -> tuple[str, Any]:
self._check_frozen()
return ("", None) # Never reached
def clear(self) -> None:
self._check_frozen()
def update(self, *args: Any, **kwargs: Any) -> None:
self._check_frozen()
def setdefault(self, key: str, default: Any = None) -> Any:
self._check_frozen()
return None # Never reached
def __deepcopy__(self, memo: dict[int, object]) -> ImmutableDict:
"""Support deepcopy by creating a new immutable dict with copied data."""
# Deep copy the internal data
new_data = {}
for key, value in self.items():
new_data[key] = copy.deepcopy(value, memo)
# Create a new ImmutableDict with the copied data
return ImmutableDict(new_data)
def create_immutable_state(state: dict[str, Any]) -> ImmutableDict:
"""Create an immutable version of a state dictionary.
Args:
state: The state dictionary to make immutable.
Returns:
An ImmutableDict that prevents modifications.
"""
# Deep copy to prevent references to mutable objects
immutable_state = ImmutableDict(copy.deepcopy(state))
return immutable_state
def update_state_immutably(
current_state: dict[str, Any], updates: dict[str, Any]
) -> dict[str, Any]:
"""Create a new state with updates applied immutably.
This function creates a new state object by merging the current state
with updates, without modifying the original state.
Args:
current_state: The current state dictionary.
updates: Updates to apply to the state.
Returns:
A new state dictionary with updates applied.
"""
# Deep copy the current state into a regular dict
# If it's an ImmutableDict, convert to regular dict first
if isinstance(current_state, ImmutableDict):
new_state = {}
for key, value in current_state.items():
new_state[key] = copy.deepcopy(value)
else:
new_state = copy.deepcopy(current_state)
# Apply updates
for key, value in updates.items():
# Check for replacement marker
if isinstance(value, tuple) and len(value) == 2 and value[0] == "__REPLACE__":
# Force replacement, ignore existing value
new_state[key] = value[1]
elif (
key in new_state
and isinstance(new_state[key], list)
and isinstance(value, list)
):
# For lists, create a new list (don't extend in place)
new_state[key] = new_state[key] + value
elif (
key in new_state
and isinstance(new_state[key], dict)
and isinstance(value, dict)
):
# For dicts, merge recursively
new_state[key] = {**new_state[key], **value}
else:
# Direct assignment for other types
new_state[key] = value
return new_state
def ensure_immutable_node(node_func: CallableT) -> CallableT:
"""Decorator to ensure a node function treats state as immutable.
This decorator:
1. Converts the input state to an immutable version
2. Ensures the node returns a new state object
3. Validates that the original state wasn't modified
Example:
```python
@ensure_immutable_node
async def my_node(state: dict) -> dict:
# This will raise ImmutableStateError
# state["key"] = "value"
# This is the correct pattern
return update_state_immutably(state, {"key": "value"})
```
"""
import functools
import inspect
@functools.wraps(node_func)
async def async_wrapper(*args: object, **kwargs: object) -> object:
# Extract state from args (assuming it's the first argument)
if not args:
result = node_func(*args, **kwargs)
if inspect.iscoroutine(result):
return await result
return result
state = args[0]
if not isinstance(state, dict):
result = node_func(*args, **kwargs)
if inspect.iscoroutine(result):
return await result
return result
# Create a snapshot of the original state for comparison
original_snapshot = copy.deepcopy(state)
# Create immutable state
immutable_state = create_immutable_state(state)
# Call node with immutable state
new_args = (immutable_state,) + args[1:]
result = node_func(*new_args, **kwargs)
if inspect.iscoroutine(result):
result = await result
# Verify original state wasn't modified (belt and suspenders)
if state != original_snapshot:
raise ImmutableStateError(
f"Node {node_func.__name__} modified the input state. "
"Nodes must return new state objects instead of mutating."
)
return result
@functools.wraps(node_func)
def sync_wrapper(*args: object, **kwargs: object) -> object:
# Extract state from args (assuming it's the first argument)
if not args:
return node_func(*args, **kwargs)
state = args[0]
if not isinstance(state, dict):
return node_func(*args, **kwargs)
# Create a snapshot of the original state for comparison
original_snapshot = copy.deepcopy(state)
# Create immutable state
immutable_state = create_immutable_state(state)
# Call node with immutable state
new_args = (immutable_state,) + args[1:]
result = node_func(*new_args, **kwargs)
# Verify original state wasn't modified (belt and suspenders)
if state != original_snapshot:
raise ImmutableStateError(
f"Node {node_func.__name__} modified the input state. "
"Nodes must return new state objects instead of mutating."
)
return result
# Return appropriate wrapper based on function type
if inspect.iscoroutinefunction(node_func):
return cast("CallableT", async_wrapper)
else:
return cast("CallableT", sync_wrapper)
class StateUpdater:
"""Builder pattern for immutable state updates.
This class provides a fluent API for building state updates
without mutating the original state.
Example:
```python
updater = StateUpdater(current_state)
new_state = (
updater
.set("key", "value")
.append("messages", new_message)
.merge("config", {"temperature": 0.7})
.build()
)
```
"""
def __init__(self, base_state: dict[str, Any]):
"""Initialize with a base state.
Args:
base_state: The starting state.
"""
# Handle both regular dicts and ImmutableDict
if isinstance(base_state, ImmutableDict):
# Convert ImmutableDict to regular dict for internal use
self._state: dict[str, Any] = {}
for key, value in base_state.items():
self._state[key] = copy.deepcopy(value)
else:
# Regular dict can be deepcopied normally
self._state = copy.deepcopy(base_state)
self._updates: dict[str, Any] = {}
def set(self, key: str, value: Any) -> StateUpdater:
"""Set a value in the state.
Args:
key: The key to set.
value: The value to set.
Returns:
Self for chaining.
"""
self._updates[key] = value
return self
def replace(self, key: str, value: Any) -> StateUpdater:
"""Replace a value in the state, ignoring existing value.
This forces replacement even for dicts and lists, unlike set()
which merges dicts and concatenates lists.
Args:
key: The key to set.
value: The value to set.
Returns:
Self for chaining.
"""
self._updates[key] = ("__REPLACE__", value)
return self
def append(self, key: str, value: Any) -> StateUpdater:
"""Append to a list in the state.
Args:
key: The key of the list to append to.
value: The value to append.
Returns:
Self for chaining.
"""
current_list = self._state.get(key, [])
if not isinstance(current_list, list):
raise ValueError(f"Cannot append to non-list value at key '{key}'")
if key not in self._updates:
self._updates[key] = list(current_list)
if isinstance(self._updates[key], list):
self._updates[key].append(value)
return self
def extend(self, key: str, values: list[Any]) -> StateUpdater:
"""Extend a list in the state.
Args:
key: The key of the list to extend.
values: The values to add.
Returns:
Self for chaining.
"""
current_list = self._state.get(key, [])
if not isinstance(current_list, list):
raise ValueError(f"Cannot extend non-list value at key '{key}'")
if key not in self._updates:
self._updates[key] = list(current_list)
if isinstance(self._updates[key], list):
self._updates[key].extend(values)
return self
def merge(self, key: str, updates: dict[str, Any]) -> StateUpdater:
"""Merge updates into a dict in the state.
Args:
key: The key of the dict to merge into.
updates: The updates to merge.
Returns:
Self for chaining.
"""
current_dict = self._state.get(key, {})
if not isinstance(current_dict, dict):
raise ValueError(f"Cannot merge into non-dict value at key '{key}'")
if key not in self._updates:
self._updates[key] = dict(current_dict)
if isinstance(self._updates[key], dict):
self._updates[key].update(updates)
return self
def increment(self, key: str, amount: int | float = 1) -> StateUpdater:
"""Increment a numeric value in the state.
Args:
key: The key of the value to increment.
amount: The amount to increment by.
Returns:
Self for chaining.
"""
current_value = self._state.get(key, 0)
if not isinstance(current_value, int | float):
raise ValueError(f"Cannot increment non-numeric value at key '{key}'")
self._updates[key] = current_value + amount
return self
def build(self) -> dict[str, Any]:
"""Build the final state with all updates applied.
Returns:
A new state dictionary with updates applied.
"""
return update_state_immutably(self._state, self._updates)
def validate_state_schema(state: dict[str, Any], schema: type) -> None:
"""Validate that a state conforms to a schema.
Args:
state: The state to validate.
schema: A TypedDict or Pydantic model class defining the schema.
Raises:
ValueError: If the state doesn't conform to the schema.
"""
# This is a simplified implementation
# In practice, you'd use Pydantic or similar for validation
if hasattr(schema, "__annotations__"):
for key, _expected_type in schema.__annotations__.items():
if key not in state and not (
hasattr(schema, "__total__") and not getattr(schema, "__total__", True)
):
raise ValueError(f"Required field '{key}' missing from state")
if key in state:
state[key]
# Basic type checking (would be more sophisticated in practice)
# Skip validation for now to avoid complex type checking
pass

View File

@@ -21,6 +21,10 @@ _console = Console(stderr=True, force_terminal=True if os.isatty(2) else None)
class SafeRichHandler(RichHandler):
"""RichHandler that safely handles exceptions without recursion."""
def __init__(self, *args, **kwargs) -> None:
"""Initialize the SafeRichHandler with proper argument passing."""
super().__init__(*args, **kwargs)
def emit(self, record: logging.LogRecord) -> None:
"""Emit a record with safe exception handling."""
try:

View File

@@ -51,7 +51,7 @@ def create_rich_formatter() -> logging.Formatter:
return RichFormatter()
def format_dict_as_table(data: dict, title: str | None = None) -> Table:
def format_dict_as_table(data: dict[str, object], title: str | None = None) -> Table:
"""Format a dictionary as a Rich table.
Args:
@@ -72,7 +72,9 @@ def format_dict_as_table(data: dict, title: str | None = None) -> Table:
def format_list_as_table(
data: list[dict], columns: list[str] | None = None, title: str | None = None
data: list[dict[str, object]],
columns: list[str] | None = None,
title: str | None = None,
) -> Table:
"""Format a list of dictionaries as a Rich table.

View File

@@ -42,8 +42,10 @@ def log_function_call(
# Log function call
message_parts = [f"Calling {func.__name__}"]
if include_args and (args or kwargs):
arg_strs = [repr(arg) for arg in cast(tuple, args)]
kwarg_strs = [f"{k}={v!r}" for k, v in cast(dict, kwargs).items()]
arg_strs = [repr(arg) for arg in cast("tuple[object, ...]", args)]
kwarg_strs = [
f"{k}={v!r}" for k, v in cast("dict[str, object]", kwargs).items()
]
all_args = ", ".join(arg_strs + kwarg_strs)
message_parts.append(f"({all_args})")
@@ -135,7 +137,9 @@ def structured_log(
logger.critical(message)
def log_context(operation: str, **context: str | int | float | bool) -> dict:
def log_context(
operation: str, **context: str | int | float | bool
) -> dict[str, object]:
"""Create a structured logging context.
Args:

View File

@@ -8,7 +8,7 @@ T = TypeVar("T")
R = TypeVar("R")
async def gather_with_concurrency(
async def gather_with_concurrency[T](
n: int,
tasks: Sequence[Coroutine[None, None, T]],
) -> list[T]:
@@ -34,7 +34,7 @@ async def gather_with_concurrency(
return list(results)
async def retry_async(
async def retry_async[T](
func: Callable[..., Awaitable[T]],
max_attempts: int = 3,
delay: float = 1.0,
@@ -66,7 +66,7 @@ async def retry_async(
# Check if exception is in the allowed list
is_allowed = False
for exc_type in exceptions:
if isinstance(e, cast(type, exc_type)):
if isinstance(e, cast("type", exc_type)):
is_allowed = True
break
if not is_allowed:

View File

@@ -59,7 +59,7 @@ class HTTPClient:
# Using cast to work around type checking issues
from typing import Any, cast
timeout = cast(Any, aiohttp.ClientTimeout)(
timeout = cast("Any", aiohttp.ClientTimeout)(
total=self.config.timeout,
connect=self.config.connect_timeout,
)
@@ -102,12 +102,12 @@ class HTTPClient:
if timeout := options.get("timeout"):
if isinstance(timeout, tuple):
# Use Any cast to work around pyrefly's ClientTimeout constructor issues
timeout_obj = cast(Any, ClientTimeout)(
timeout_obj = cast("Any", ClientTimeout)(
total=timeout[1],
connect=timeout[0],
)
else:
timeout_obj = cast(Any, ClientTimeout)(total=timeout)
timeout_obj = cast("Any", ClientTimeout)(total=timeout)
# Build kwargs dict - start with known types
kwargs: dict[str, Any] = {}
@@ -175,25 +175,30 @@ class HTTPClient:
async def get(self, url: str, **kwargs) -> HTTPResponse:
"""Make a GET request."""
options: RequestOptions = {"method": "GET", "url": url, **kwargs}
options_dict = {"method": "GET", "url": url, **kwargs}
options = cast("RequestOptions", options_dict)
return await self.request(options)
async def post(self, url: str, **kwargs) -> HTTPResponse:
"""Make a POST request."""
options: RequestOptions = {"method": "POST", "url": url, **kwargs}
options_dict = {"method": "POST", "url": url, **kwargs}
options = cast("RequestOptions", options_dict)
return await self.request(options)
async def put(self, url: str, **kwargs) -> HTTPResponse:
"""Make a PUT request."""
options: RequestOptions = {"method": "PUT", "url": url, **kwargs}
options_dict = {"method": "PUT", "url": url, **kwargs}
options = cast("RequestOptions", options_dict)
return await self.request(options)
async def delete(self, url: str, **kwargs) -> HTTPResponse:
"""Make a DELETE request."""
options: RequestOptions = {"method": "DELETE", "url": url, **kwargs}
options_dict = {"method": "DELETE", "url": url, **kwargs}
options = cast("RequestOptions", options_dict)
return await self.request(options)
async def patch(self, url: str, **kwargs) -> HTTPResponse:
"""Make a PATCH request."""
options: RequestOptions = {"method": "PATCH", "url": url, **kwargs}
options_dict = {"method": "PATCH", "url": url, **kwargs}
options = cast("RequestOptions", options_dict)
return await self.request(options)

View File

@@ -79,11 +79,11 @@ async def retry_with_backoff(
if asyncio.iscoroutinefunction(func):
# For async functions
result = await func(*args, **kwargs)
return cast(T, result)
return cast("T", result)
else:
# For sync functions
result = func(*args, **kwargs)
return cast(T, result)
return cast("T", result)
except Exception as e:
# Check if exception is in the allowed list
if not any(isinstance(e, exc_type) for exc_type in config.exceptions):

View File

@@ -46,6 +46,8 @@ Example:
from typing import Any
# These imports need to be updated based on where these modules live
# For now, importing from the main app until we determine the correct structure
from biz_bud.config.loader import load_config_async
from biz_bud.config.schemas import AppConfig
from biz_bud.services.factory import ServiceFactory
@@ -169,6 +171,7 @@ def get_service_factory_sync(state: dict[str, Any]) -> ServiceFactory:
available. For asynchronous nodes, prefer get_service_factory() which
can handle async configuration loading and service initialization.
"""
# Import within function to avoid circular imports
from biz_bud.config.loader import load_config
# Load base configuration

View File

@@ -26,3 +26,60 @@ class ResourceIdentifier(TypedDict):
type: str
id: str
namespace: NotRequired[str]
class EntityReference(TypedDict):
"""Reference to an entity."""
name: str
type: str
id: NotRequired[str]
confidence: NotRequired[float]
metadata: NotRequired[Metadata]
class ExtractedEntity(TypedDict):
"""An extracted entity with metadata."""
name: str
type: str
value: str
confidence: float
span: NotRequired[tuple[int, int]]
metadata: NotRequired[Metadata]
class DocumentMetadata(TypedDict):
"""Metadata for a document."""
title: NotRequired[str]
source: NotRequired[str]
url: NotRequired[str]
timestamp: NotRequired[str]
content_type: NotRequired[str]
language: NotRequired[str]
size: NotRequired[int]
checksum: NotRequired[str]
tags: NotRequired[list[str]]
class SearchResult(TypedDict):
"""A search result."""
title: str
url: str
content: str
relevance_score: NotRequired[float]
metadata: NotRequired[DocumentMetadata]
summary: NotRequired[str]
class AnalysisResult(TypedDict):
"""Result of an analysis operation."""
status: Literal["success", "failure", "partial"]
confidence: float
entities: NotRequired[list[ExtractedEntity]]
summary: NotRequired[str]
metadata: NotRequired[Metadata]
errors: NotRequired[list[str]]

View File

@@ -21,7 +21,7 @@ from datetime import datetime
from typing import Any, TypeVar
from bb_utils.core.unified_logging import get_logger
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
T = TypeVar("T")
logger = get_logger(__name__)
@@ -160,16 +160,18 @@ class ValidationContext:
class ValidationResult(BaseModel):
"""Result of validation operation."""
is_valid: bool
is_valid: bool = True
errors: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
data: Any = None # noqa: ANN401
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("errors", "warnings", mode="after")
def limit_messages(cls, v: list[str]) -> list[str]:
"""Limit number of messages to prevent memory issues."""
return v[:1000]
def model_post_init(self, __context: Any) -> None:
"""Limit messages to prevent memory issues."""
if len(self.errors) > 1000:
self.errors = self.errors[:1000]
if len(self.warnings) > 1000:
self.warnings = self.warnings[:1000]
# === Composite Validators ===
@@ -314,7 +316,7 @@ class SchemaValidator:
def validate(self, data: dict[str, Any]) -> ValidationResult:
"""Validate data against schema."""
result = ValidationResult(is_valid=True)
result = ValidationResult()
# Check required fields
for field, field_schema in self.schema.items():
@@ -368,7 +370,7 @@ class DataQualityValidator:
self, data: dict[str, Any], required_fields: list[str]
) -> ValidationResult:
"""Check if all required fields are present and non-empty."""
result = ValidationResult(is_valid=True)
result = ValidationResult()
for field in required_fields:
if field not in data:
@@ -387,7 +389,7 @@ class DataQualityValidator:
def validate_consistency(self, data: list[dict[str, Any]]) -> ValidationResult:
"""Check consistency across multiple records."""
result = ValidationResult(is_valid=True)
result = ValidationResult()
if not data:
return result
@@ -416,7 +418,7 @@ class DataQualityValidator:
self, data: object, validation_rules: list[ValidationRule]
) -> ValidationResult:
"""Validate data accuracy using custom rules."""
result = ValidationResult(is_valid=True)
result = ValidationResult()
for rule in validation_rules:
error = rule.validate(data)
@@ -436,7 +438,7 @@ class DataQualityValidator:
self, timestamp: datetime, max_age_seconds: float
) -> ValidationResult:
"""Check if data is recent enough."""
result = ValidationResult(is_valid=True)
result = ValidationResult()
age = (datetime.now() - timestamp).total_seconds()

View File

@@ -2,9 +2,7 @@
import json
import re
from typing import Literal
from typing_extensions import TypedDict
from typing import Literal, TypedDict
ContentType = Literal["html", "json", "xml", "text", "markdown"]

View File

@@ -20,7 +20,7 @@ def _check_type(value: object, expected_type: object) -> bool:
if hasattr(expected_type, "__origin__"):
return True
# For simple types, use isinstance
return isinstance(value, cast(type, expected_type))
return isinstance(value, cast("type", expected_type))
except (TypeError, AttributeError):
# If we can't check, assume it's OK
return True
@@ -56,9 +56,11 @@ def validate_args(
# Get function signature
import inspect
sig = inspect.signature(cast(Callable, func))
sig = inspect.signature(cast("Callable[..., object]", func))
# Convert ParamSpec args/kwargs to regular tuple/dict for binding
bound_args = sig.bind(*cast(tuple, args), **cast(dict, kwargs))
bound_args = sig.bind(
*cast("tuple[object, ...]", args), **cast("dict[str, object]", kwargs)
)
bound_args.apply_defaults()
# Validate each argument
@@ -71,7 +73,7 @@ def validate_args(
f"Invalid argument '{arg_name}': {error_msg}"
)
return func(*args, **kwargs)
return func(*cast("P.args", args), **cast("P.kwargs", kwargs))
return wrapper
@@ -130,9 +132,11 @@ def validate_not_none(
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import inspect
sig = inspect.signature(cast(Callable, func))
sig = inspect.signature(cast("Callable[..., object]", func))
# Convert ParamSpec args/kwargs to regular tuple/dict for binding
bound_args = sig.bind(*cast(tuple, args), **cast(dict, kwargs))
bound_args = sig.bind(
*cast("tuple[object, ...]", args), **cast("dict[str, object]", kwargs)
)
bound_args.apply_defaults()
for arg_name in arg_names:
@@ -142,7 +146,7 @@ def validate_not_none(
):
raise ValidationError(f"Argument '{arg_name}' cannot be None")
return func(*args, **kwargs)
return func(*cast("P.args", args), **cast("P.kwargs", kwargs))
return wrapper
@@ -173,9 +177,11 @@ def validate_types(
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import inspect
sig = inspect.signature(cast(Callable, func))
sig = inspect.signature(cast("Callable[..., object]", func))
# Convert ParamSpec args/kwargs to regular tuple/dict for binding
bound_args = sig.bind(*cast(tuple, args), **cast(dict, kwargs))
bound_args = sig.bind(
*cast("tuple[object, ...]", args), **cast("dict[str, object]", kwargs)
)
bound_args.apply_defaults()
for arg_name, expected_type in expected_types.items():
@@ -190,7 +196,7 @@ def validate_types(
f"got {actual_type}"
)
return func(*args, **kwargs)
return func(*cast("P.args", args), **cast("P.kwargs", kwargs))
return wrapper

View File

@@ -20,6 +20,7 @@ import pickle
import tempfile
import time
from pathlib import Path
from typing import cast
from urllib.parse import urlparse
import requests
@@ -41,23 +42,25 @@ def _get_cache_path(key: str) -> str:
return os.path.join(_CACHE_DIR, f"{key}.cache")
def _get_from_cache(key: str) -> dict | None:
def _get_from_cache(key: str) -> dict[str, object] | None:
path = _get_cache_path(key)
if not os.path.exists(path):
return None
try:
with open(path, "rb") as f:
data = pickle.load(f)
# Check TTL
# Check TTL and validate data structure
if not isinstance(data, dict):
return None
if time.time() - data.get("timestamp", 0) > _CACHE_TTL:
os.remove(path)
return None
return data
return cast("dict[str, object]", data)
except Exception:
return None
def _set_to_cache(key: str, value: dict) -> None:
def _set_to_cache(key: str, value: dict[str, object]) -> None:
path = _get_cache_path(key)
value = dict(value)
value["timestamp"] = time.time()
@@ -112,7 +115,7 @@ async def download_document(url: str, timeout: int = 30) -> bytes | None:
logger.info(f"Downloading document from {url}")
try:
response = await asyncio.to_thread(
requests.get, url, stream=True, timeout=timeout
lambda: requests.get(url, stream=True, timeout=timeout)
)
response.raise_for_status()
return response.content
@@ -169,9 +172,11 @@ async def process_document(url: str, content: bytes | None = None) -> tuple[str,
if cached_result and isinstance(cached_result, dict):
logger.info(f"Using cached document extraction for {url}")
text = cached_result.get("text", "")
content_type = cached_result.get("content_type", document_type)
return (
cached_result.get("text", ""),
cached_result.get("content_type", document_type),
str(text) if text is not None else "",
str(content_type) if content_type is not None else document_type,
)
# Download content if not provided
@@ -184,14 +189,14 @@ async def process_document(url: str, content: bytes | None = None) -> tuple[str,
file_ext = os.path.splitext(Path(urlparse(url).path).name)[1].lower()
loop = asyncio.get_event_loop()
try:
temp_file_path, temp_file_name = await loop.run_in_executor(
temp_file_path, _ = await loop.run_in_executor(
None, create_temp_document_file, content, file_ext
)
except OSError as e:
logger.error(f"Failed to create temporary file: {e} for {url}")
raise ValueError(f"Failed to process document: {e}") from e
extracted_text = None
extracted_text: str | None = None
try:
# Extract text
try:
@@ -203,7 +208,7 @@ async def process_document(url: str, content: bytes | None = None) -> tuple[str,
raise ValueError(f"Failed to process document: {e}") from e
# Cache the result
cache_result = {
cache_result: dict[str, object] = {
"text": extracted_text,
"content_type": document_type,
}
@@ -232,5 +237,6 @@ async def process_document(url: str, content: bytes | None = None) -> tuple[str,
logger.warning(
f"Error cleaning up temporary file {temp_file_path}: {e}"
)
# Explicit return to satisfy linter/type checker
return "", document_type
# This should never be reached due to return/raise in try/except blocks
raise RuntimeError("Unexpected code path in process_document")

View File

@@ -144,7 +144,7 @@ def add_exception_error(state: StateDict, exc: Exception, source: str) -> StateD
errors = state.get("errors", [])
if not isinstance(errors, list):
errors = []
error_entry: dict = {
error_entry: dict[str, object] = {
"message": str(exc),
"source": source,
"type": type(exc).__name__,
@@ -290,7 +290,7 @@ def validate_node_output(output_model: type[BaseModel]) -> Callable[[F], F]:
return decorator
def validated_node(
def validated_node[F: Callable[..., object]](
_func: F | None = None,
*,
name: str | None = None,
@@ -333,7 +333,7 @@ def validated_node(
return decorator
else:
# Called without parameters: @validated_node
return decorator(_func)
return cast("F | Callable[[F], F]", decorator(_func))
async def validate_graph(graph: object, graph_id: str = "unknown") -> bool:
@@ -411,7 +411,3 @@ async def validate_all_graphs(
except Exception:
all_valid = False
return all_valid
# Alias for backward compatibility
create_input_model = create_validation_model

View File

@@ -13,6 +13,30 @@ import time
from typing import Any
# LEGITIMATE USE OF ANY - JSON PROCESSING MODULE
#
# This module intentionally uses typing.Any for JSON merge operations.
# This is a well-considered design decision, not a shortcut.
#
# Why Any is the correct choice here:
# 1. Dynamic merge strategies: Strategy strings like "first", "concat", "sum"
# determine operations at runtime, not compile time
# 2. Heterogeneous JSON data: Must handle str|int|float|bool|None|list|dict
# combinations that don't fit cleanly into union types
# 3. Cross-type operations: Numeric addition, string concatenation, list
# extension, and dict merging based on runtime inspection
# 4. JSON flexibility: Real-world JSON data often contains unexpected type
# combinations that static typing cannot predict
#
# Alternative approaches evaluated and rejected:
# - Recursive JsonValue union: Creates 100+ type errors due to restrictive unions
# - TypeGuards + protocols: Still fails on cross-type operations
# - Object with casts: Verbose and defeats type safety purpose
#
# This code is validated through comprehensive runtime tests rather than
# static typing, which is appropriate for dynamic JSON processing.
#
# Configuration: mypy.ini and pyrefly.toml allow explicit Any for this module.
def merge_chunk_results(
results: list[dict[str, Any]],
category: str,
@@ -54,8 +78,16 @@ def merge_chunk_results(
for r in results
if field in r and r.get(field) is not None
]
if values and field not in merged:
merged[field] = min(values) if strategy == "min" else max(values)
# Filter to only comparable values for min/max operations
comparable_values = [
v for v in values if isinstance(v, int | float | str)
]
if comparable_values and field not in merged:
merged[field] = (
min(comparable_values)
if strategy == "min"
else max(comparable_values)
)
for k, v in list(merged.items()):
if isinstance(v, list):
cleaned = [item for item in v if item != []]
@@ -221,13 +253,10 @@ def _finalize_averages(
def _handle_dict_update(merged_dict: dict[str, Any], value: dict[str, Any]) -> None:
"""Update merged_dict with non-empty, non-None values from value."""
if value is None or value == "":
return
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "":
continue
merged_dict[k] = v
for k, v in value.items():
if v is None or v == "":
continue
merged_dict[k] = v
def _handle_numeric_operation(
@@ -295,7 +324,7 @@ def _handle_list_extend(
if sublist_str not in seen_items:
seen_items.add(sublist_str)
if not isinstance(deduped_sublist, list):
deduped_sublist = [deduped_sublist]
deduped_sublist = [deduped_sublist] # type: ignore[unreachable]
merged_list.append(deduped_sublist)

View File

@@ -21,14 +21,13 @@ Functions:
- extract_noun_phrases
"""
import logging
import re
from collections import Counter
from datetime import UTC, datetime
from typing import Any
from typing import Any, TypedDict
from bb_utils.core.log_config import get_logger, info_highlight
from dateutil import parser
from typing_extensions import TypedDict
from dateutil import parser as dateutil_parser
# --- Constants (define locally; centralize if needed) ---
AUTHORITY_DOMAINS = [
@@ -51,7 +50,7 @@ HIGH_CREDIBILITY_TERMS = [
]
# --- Logging setup ---
logger = get_logger("bb_utils.validation.statistics")
logger = logging.getLogger("bb_core.validation.statistics")
# --- TypedDicts ---
@@ -60,7 +59,7 @@ class ExtractedFact(TypedDict, total=False):
text: str
source_text: str
data: dict
data: dict[str, object]
type: str
@@ -236,50 +235,27 @@ def calculate_category_quality_score(
# Category-specific logic example
if category == "market_dynamics":
info_highlight(
"Special logic for market_dynamics category applied.", category="statistics"
)
logger.info("Special logic for market_dynamics category applied.")
score += 0.01
elif category == "cost_considerations":
info_highlight(
"Special logic for cost_considerations category applied.",
category="statistics",
)
logger.info("Special logic for cost_considerations category applied.")
score += 0.005
# Log detailed breakdown.
info_highlight(
f"Category {category} quality score breakdown:", category="statistics"
logger.info(f"Category {category} quality score breakdown:")
logger.info(f" - Facts: {len(extracted_facts)}/{min_facts} min")
logger.info(f" - Sources: {len(sources)}/{min_sources} min")
logger.info(
f" - Statistical content: {len(stat_facts)}/{len(extracted_facts)} facts"
)
info_highlight(
f" - Facts: {len(extracted_facts)}/{min_facts} min", category="statistics"
)
info_highlight(
f" - Sources: {len(sources)}/{min_sources} min", category="statistics"
)
info_highlight(
f" - Statistical content: {len(stat_facts)}/{len(extracted_facts)} facts",
category="statistics",
)
info_highlight(
f" - Authoritative sources: "
f"{len(authoritative_sources)}/{len(sources)} sources",
category="statistics",
)
info_highlight(
f" - Recent sources: {recent_sources}/{len(sources)} sources",
category="statistics",
)
info_highlight(
f" - Consistency score: {consistency_score:.2f}", category="statistics"
)
info_highlight(
f" - Statistical validation score: {stat_validation_score:.2f}",
category="statistics",
)
info_highlight(
f" - Final category score: {min(1.0, score):.2f}", category="statistics"
logger.info(
f" - Authoritative sources: {len(authoritative_sources)}/{len(sources)} "
f"sources"
)
logger.info(f" - Recent sources: {recent_sources}/{len(sources)} sources")
logger.info(f" - Consistency score: {consistency_score:.2f}")
logger.info(f" - Statistical validation score: {stat_validation_score:.2f}")
logger.info(f" - Final category score: {min(1.0, score):.2f}")
return min(1.0, score)
@@ -342,8 +318,8 @@ def count_recent_sources(sources: list["Source"], recency_threshold: int) -> int
if isinstance(date_candidate, datetime):
date = date_candidate
except ValueError:
parsed_result = parser.parse(published_date)
# parser.parse may rarely return a tuple
parsed_result = dateutil_parser.parse(published_date)
# dateutil_parser.parse may rarely return a tuple
# (e.g., with fuzzy_with_tokens)
if isinstance(parsed_result, tuple):
# Take the first element if it's a datetime
@@ -407,12 +383,15 @@ def get_topics_in_fact(fact: "ExtractedFact") -> set[str]:
data = fact["data"]
if fact.get("type") == "vendor":
if "vendor_name" in data:
topics.add(data["vendor_name"].lower())
vendor_name = data["vendor_name"]
if isinstance(vendor_name, str):
topics.add(vendor_name.lower())
elif fact.get("type") == "relationship":
entities = data.get("entities", [])
for entity in entities:
if isinstance(entity, str):
topics.add(entity.lower())
if isinstance(entities, list):
for entity in entities:
if isinstance(entity, str):
topics.add(entity.lower())
elif (
fact.get("type")
in [
@@ -423,7 +402,9 @@ def get_topics_in_fact(fact: "ExtractedFact") -> set[str]:
]
and "description" in data
):
extract_noun_phrases(data["description"], topics)
description = data["description"]
if isinstance(description, str):
extract_noun_phrases(description, topics)
if "source_text" in fact and isinstance(fact["source_text"], str):
extract_noun_phrases(fact["source_text"], topics)
return topics

View File

@@ -7,7 +7,7 @@ from urllib.parse import urlparse
T = TypeVar("T")
def validate_type(value: object, expected_type: type[T]) -> tuple[bool, str | None]:
def validate_type[T](value: object, expected_type: type[T]) -> tuple[bool, str | None]:
"""Validate that a value is of the expected type.
Args:
@@ -137,7 +137,7 @@ def validate_number_range(
def validate_list_length(
value: list,
value: list[object],
min_length: int | None = None,
max_length: int | None = None,
) -> tuple[bool, str | None]:

View File

@@ -55,8 +55,8 @@ class TestRedisCache:
assert cache.url == "redis://example.com:6379"
assert cache.key_prefix == "myapp:"
assert cache._decode_responses is True
assert cache._client is None
assert cache._decode_responses is True # pyright: ignore[reportPrivateUsage]
assert cache._client is None # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_ensure_connected_success(
@@ -64,10 +64,10 @@ class TestRedisCache:
) -> None:
"""Test successful Redis connection."""
with patch("redis.asyncio.from_url", return_value=mock_redis_client):
client = await cache._ensure_connected()
client = await cache._ensure_connected() # pyright: ignore[reportPrivateUsage]
assert client == mock_redis_client
assert cache._client == mock_redis_client
assert cache._client == mock_redis_client # pyright: ignore[reportPrivateUsage]
mock_redis_client.ping.assert_called_once()
@pytest.mark.asyncio

View File

@@ -32,13 +32,14 @@ class TestEntityReference:
"name": "John Doe",
"type": "person",
"metadata": {
"role": "CEO",
"company": "TechCorp",
"location": "San Francisco",
"source": "manual_entry",
"timestamp": "2024-01-20T10:00:00Z",
"version": "1.0",
"tags": ["person", "executive"],
},
}
assert entity["metadata"]["role"] == "CEO"
assert entity["metadata"]["company"] == "TechCorp"
assert entity["metadata"]["source"] == "manual_entry"
assert entity["metadata"]["tags"] == ["person", "executive"]
class TestDocumentMetadata:
@@ -59,20 +60,16 @@ class TestDocumentMetadata:
"source": "api",
"url": "https://api.example.com/data",
"title": "Market Analysis Report",
"author": "Research Team",
"published_date": "2024-01-15",
"last_modified": "2024-01-20",
"timestamp": "2024-01-15T10:00:00Z",
"content_type": "application/json",
"language": "en",
"size": 1024,
"checksum": "abc123",
"tags": ["market", "analysis", "technology"],
"custom_fields": {
"department": "Research",
"confidence_score": 0.95,
},
}
assert metadata["title"] == "Market Analysis Report"
assert len(metadata["tags"]) == 3
assert metadata["custom_fields"]["confidence_score"] == 0.95
assert metadata["timestamp"] == "2024-01-15T10:00:00Z"
class TestSearchResult:
@@ -83,30 +80,29 @@ class TestSearchResult:
result: SearchResult = {
"title": "Search Result Title",
"url": "https://example.com/result",
"snippet": "This is a snippet of the search result...",
"content": "This is a snippet of the search result...",
}
assert result["title"] == "Search Result Title"
assert result["url"] == "https://example.com/result"
assert result["snippet"].startswith("This is a snippet")
assert result["content"].startswith("This is a snippet")
def test_search_result_with_metadata(self) -> None:
"""Test SearchResult with all fields."""
result: SearchResult = {
"title": "AI Market Trends 2024",
"url": "https://example.com/ai-trends",
"snippet": "The AI market is experiencing rapid growth...",
"source": "tavily",
"published_date": "2024-01-10",
"content": "The AI market is experiencing rapid growth...",
"relevance_score": 0.92,
"summary": "Summary of AI market trends for 2024",
"metadata": {
"author": "Industry Expert",
"category": "Technology",
"read_time": "5 minutes",
"source": "tavily",
"timestamp": "2024-01-10T08:00:00Z",
"tags": ["AI", "market", "trends"],
},
}
assert result["source"] == "tavily"
assert result["relevance_score"] == 0.92
assert result["metadata"]["read_time"] == "5 minutes"
assert result["summary"] == "Summary of AI market trends for 2024"
assert result["metadata"]["source"] == "tavily"
class TestExtractedEntity:
@@ -115,31 +111,32 @@ class TestExtractedEntity:
def test_minimal_extracted_entity(self) -> None:
"""Test creating a minimal ExtractedEntity."""
entity: ExtractedEntity = {
"text": "Apple Inc.",
"name": "Apple Inc.",
"type": "ORGANIZATION",
"start": 10,
"end": 20,
"value": "Apple Inc.",
"confidence": 0.95,
"span": (10, 20),
}
assert entity["text"] == "Apple Inc."
assert entity["name"] == "Apple Inc."
assert entity["type"] == "ORGANIZATION"
assert entity["end"] - entity["start"] == 10
assert entity["span"] == (10, 20)
def test_extracted_entity_with_confidence(self) -> None:
"""Test ExtractedEntity with confidence and metadata."""
entity: ExtractedEntity = {
"text": "San Francisco",
"name": "San Francisco",
"type": "LOCATION",
"start": 45,
"end": 58,
"value": "San Francisco",
"confidence": 0.98,
"span": (45, 58),
"metadata": {
"country": "USA",
"state": "California",
"population": 873965,
"source": "location_database",
"timestamp": "2024-01-20T10:00:00Z",
"tags": ["city", "california"],
},
}
assert entity["confidence"] == 0.98
assert entity["metadata"]["state"] == "California"
assert entity["metadata"]["source"] == "location_database"
class TestAnalysisResult:
@@ -148,62 +145,63 @@ class TestAnalysisResult:
def test_minimal_analysis_result(self) -> None:
"""Test creating a minimal AnalysisResult."""
result: AnalysisResult = {
"analysis_type": "sentiment",
"results": {
"sentiment": "positive",
"score": 0.85,
},
"status": "success",
"confidence": 0.85,
"summary": "Positive sentiment analysis with high confidence",
}
assert result["analysis_type"] == "sentiment"
assert result["results"]["sentiment"] == "positive"
assert result["status"] == "success"
assert result["confidence"] == 0.85
def test_analysis_result_with_metadata(self) -> None:
"""Test AnalysisResult with complete metadata."""
result: AnalysisResult = {
"analysis_type": "market_analysis",
"results": {
"market_size": "$10B",
"growth_rate": "15%",
"key_players": ["Company A", "Company B", "Company C"],
},
"status": "success",
"confidence": 0.9,
"timestamp": datetime(2024, 1, 15, 10, 30, 0).isoformat(),
"summary": "Market analysis completed with comprehensive data coverage",
"metadata": {
"model_version": "2.0",
"data_sources": ["financial_reports", "news_articles"],
"analysis_duration": 45.2,
"source": "financial_reports",
"timestamp": datetime(2024, 1, 15, 10, 30, 0).isoformat(),
"version": "2.0",
"tags": ["market", "analysis", "financial"],
},
"errors": [],
}
assert result["confidence"] == 0.9
assert len(result["results"]["key_players"]) == 3
assert result["metadata"]["analysis_duration"] == 45.2
assert result["status"] == "success"
assert result["metadata"]["source"] == "financial_reports"
def test_analysis_result_nested_results(self) -> None:
"""Test AnalysisResult with complex nested results."""
result: AnalysisResult = {
"analysis_type": "competitive_analysis",
"results": {
"competitors": {
"direct": ["Competitor A", "Competitor B"],
"indirect": ["Competitor C", "Competitor D"],
},
"market_position": {
"rank": 2,
"market_share": 0.25,
"trend": "increasing",
},
"strengths": ["Brand recognition", "Technology"],
"weaknesses": ["Limited geographic presence"],
},
"status": "success",
"confidence": 0.88,
"summary": "Competitive analysis completed with detailed market insights",
"entities": [
{
"name": "Competitor A",
"type": "ORGANIZATION",
"value": "Competitor A",
"confidence": 0.95,
},
{
"name": "Competitor B",
"type": "ORGANIZATION",
"value": "Competitor B",
"confidence": 0.92,
},
],
"metadata": {
"analysis_method": "SWOT",
"last_updated": "2024-01-20",
"source": "competitive_analysis",
"timestamp": "2024-01-20T10:00:00Z",
"tags": ["competitive", "market", "analysis"],
},
}
assert len(result["results"]["competitors"]["direct"]) == 2
assert result["results"]["market_position"]["rank"] == 2
assert "Brand recognition" in result["results"]["strengths"]
assert len(result["entities"]) == 2
assert result["entities"][0]["name"] == "Competitor A"
assert (
result["summary"]
== "Competitive analysis completed with detailed market insights"
)
class TestTypeValidation:
@@ -242,26 +240,22 @@ class TestTypeValidation:
result: SearchResult = {
"title": "Result",
"url": "https://example.com",
"snippet": "Snippet",
"content": "Snippet",
}
assert "source" not in result
assert "metadata" not in result
def test_custom_fields_flexibility(self) -> None:
"""Test that custom fields can contain various types."""
def test_metadata_flexibility(self) -> None:
"""Test that metadata contains various types."""
metadata: DocumentMetadata = {
"source": "api",
"url": "https://example.com",
"custom_fields": {
"string_field": "value",
"number_field": 42,
"float_field": 3.14,
"bool_field": True,
"list_field": [1, 2, 3],
"dict_field": {"nested": "value"},
"null_field": None,
},
"title": "Test Document",
"content_type": "application/json",
"language": "en",
"size": 1024,
"tags": ["test", "document", "api"],
}
assert metadata["custom_fields"]["number_field"] == 42
assert metadata["custom_fields"]["bool_field"] is True
assert metadata["custom_fields"]["null_field"] is None
assert metadata["title"] == "Test Document"
assert metadata["size"] == 1024
assert "test" in metadata["tags"]

View File

@@ -9,6 +9,7 @@ description = "Unified data extraction utilities for the Business Buddy framewor
requires-python = ">=3.12"
dependencies = [
"business-buddy-core @ {root:uri}/../business-buddy-core",
"business-buddy-utils @ {root:uri}/../business-buddy-utils",
"pydantic>=2.10.0,<2.11",
"typing-extensions>=4.13.2,<4.14.0",
"beautifulsoup4>=4.13.4",
@@ -150,5 +151,4 @@ disallow_incomplete_defs = false
# Pyrefly configuration
[tool.pyrefly]
paths = ["src", "tests"]
python_version = "3.12"

View File

@@ -0,0 +1,51 @@
# Pyrefly configuration for business-buddy-extraction package
# Include source directories
project_includes = [
"src/bb_extraction",
"tests"
]
# Exclude directories
project_excludes = [
"build/",
"dist/",
".venv/",
"venv/",
"**/__pycache__/",
"**/htmlcov/",
"**/.pytest_cache/",
"**/.mypy_cache/",
"**/.ruff_cache/",
"**/*.egg-info/"
]
# Search paths for module resolution - include tests for helpers module
search_path = [
"src",
"tests"
]
# Python version
python_version = "3.12.0"
# Libraries to ignore missing imports
replace_imports_with_any = [
"pytest",
"pytest.*",
"nltk.*",
"tiktoken.*",
"requests",
"requests.*",
"aiohttp",
"aiohttp.*",
"langchain_core.*",
"langchain.*",
"pydantic.*",
"json_repair.*",
"beautifulsoup4.*",
"lxml.*",
"hypothesis.*",
"bb_utils",
"bb_utils.*"
]

View File

@@ -19,8 +19,6 @@ Usage:
)
"""
from __future__ import annotations
# Import from core submodule
from .core import (
# Patterns
@@ -113,10 +111,9 @@ from .text import (
# Tools (optional)
try:
from .tools import CategoryExtractionTool, StatisticsExtractionTool
from .tools import CategoryExtractionTool
except ImportError:
CategoryExtractionTool = None # type: ignore[assignment,misc]
StatisticsExtractionTool = None # type: ignore[assignment,misc]
__all__ = [
# From core

View File

@@ -4,9 +4,7 @@ This module consolidates all type definitions used across the extraction
package, including TypedDict definitions for facts, citations, and results.
"""
from typing import TYPE_CHECKING, Literal, NotRequired
from typing_extensions import TypedDict
from typing import Any, Literal, NotRequired, TypedDict
class CitationTypedDict(TypedDict, total=False):
@@ -97,7 +95,7 @@ class CompanyExtractionResultTypedDict(TypedDict, total=False):
confidence: float
sources: NotRequired[set[str]]
url: NotRequired[str]
source: NotRequired[str] # For legacy compatibility
source: NotRequired[str]
source_type: NotRequired[str] # Type of source (title, snippet, etc.)
source_idx: NotRequired[int] # Index of the source result
@@ -122,11 +120,7 @@ class MetadataExtraction(TypedDict):
# JSON and structured data types
if TYPE_CHECKING:
JsonValue = str | int | float | bool | None | dict[str, "JsonValue"] | list["JsonValue"]
else:
# For runtime, use a simpler definition
JsonValue = str | int | float | bool | None | dict | list
JsonValue = str | int | float | bool | None | dict[str, Any] | list[Any]
JsonDict = dict[str, JsonValue]
# Action and argument types

View File

@@ -5,6 +5,10 @@ from .company_extraction import (
extract_companies_from_search_results,
extract_company_names,
)
from .component_extractor import (
ComponentCategorizer,
ComponentExtractor,
)
from .entity_extraction import (
EntityExtractor,
EntityType,
@@ -29,6 +33,9 @@ __all__ = [
"add_source_metadata",
"extract_companies_from_search_results",
"extract_company_names",
# Component extraction
"ComponentCategorizer",
"ComponentExtractor",
# Entity extraction
"EntityExtractor",
"EntityType",

View File

@@ -1,487 +1,493 @@
"""Company name extraction utilities.
This module provides functionality for extracting company names from text,
including pattern matching, confidence scoring, and deduplication.
"""
import re
from typing import cast
from ..core.extraction_patterns import (
ARTICLES_PREPOSITIONS,
COMMON_SUFFIXES,
COMPANY_INDICATORS,
SKIP_PHRASES,
)
from ..core.types import CompanyExtractionResultTypedDict
def extract_company_names(
text: str,
known_companies: set[str] | None = None,
min_confidence: float = 0.3,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract company names from text.
Args:
text: The text to extract company names from
known_companies: Optional set of known company names for validation
min_confidence: Minimum confidence threshold for results
Returns:
List of extracted company names with confidence scores
"""
if not text:
return []
results: list[CompanyExtractionResultTypedDict] = []
# Extract using company indicators (suffixes) first - these have higher confidence
indicator_companies = _extract_companies_by_indicators(text)
results.extend(indicator_companies)
# Extract using pattern matching (capitalization) - but exclude already found companies
# to avoid lower confidence duplicates
found_names = {comp["name"].lower() for comp in indicator_companies}
pattern_companies = _extract_companies_by_patterns(text)
# Filter out companies already found by indicator matching
for comp in pattern_companies:
if comp["name"].lower() not in found_names:
results.append(comp)
# Check known companies if provided
if known_companies:
for company in known_companies:
if company.lower() in text.lower():
results.append(
{
"name": company,
"confidence": 0.95,
"source": "known",
}
)
# Merge duplicates
merged: dict[str, CompanyExtractionResultTypedDict] = {}
for result in results:
normalized = _normalize_company_name(result["name"])
if normalized not in merged or result["confidence"] > merged[normalized]["confidence"]:
merged[normalized] = result
# Convert to list and filter by confidence
final_results = list(merged.values())
return _deduplicate_and_filter_results(final_results, min_confidence)
def extract_companies_from_search_results(
search_results: list[dict[str, str]],
min_confidence: float = 0.3,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract company names from search results.
Args:
search_results: List of search result dicts with 'title' and 'snippet' keys
min_confidence: Minimum confidence threshold
Returns:
List of extracted company names with metadata
"""
all_companies: list[CompanyExtractionResultTypedDict] = []
for idx, result in enumerate(search_results):
# Extract from title
title_companies = extract_company_names(
result.get("title", ""),
min_confidence=min_confidence + 0.1, # Higher confidence for titles
)
for company in title_companies:
# Create new dict with additional fields since TypedDict is read-only
enriched_company = cast(
"CompanyExtractionResultTypedDict",
{
**company,
"url": result.get("url", ""),
"source_type": "title",
"source_idx": idx,
},
)
all_companies.append(enriched_company)
# Extract from snippet
snippet_companies = extract_company_names(
result.get("snippet", ""), min_confidence=min_confidence
)
for company in snippet_companies:
# Create new dict with additional fields since TypedDict is read-only
enriched_company = cast(
"CompanyExtractionResultTypedDict",
{
**company,
"url": result.get("url", ""),
"source_type": "snippet",
"source_idx": idx,
},
)
all_companies.append(enriched_company)
# Deduplicate across all results
return _deduplicate_and_filter_results(all_companies, min_confidence)
def add_source_metadata(
source_type: str,
company: CompanyExtractionResultTypedDict,
result_index: int,
url: str,
) -> None:
"""Add source metadata to company extraction result."""
company["sources"] = company.get("sources", set())
if isinstance(company["sources"], set):
company["sources"].add(f"{source_type}_{result_index}")
company["url"] = url
# === Private Helper Functions ===
def _extract_companies_by_indicators(
text: str,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies using suffix indicators like Inc., LLC, etc."""
results: list[CompanyExtractionResultTypedDict] = []
# COMPANY_INDICATORS already contains regex patterns, so don't escape them
indicators_pattern = f"({'|'.join(COMPANY_INDICATORS)})"
# Simpler pattern that's more reliable
# Captures: One or more capitalized words (including & between them) followed by a company indicator
# Use word boundary at the start to avoid matching mid-sentence
# Use [^\S\n] to match whitespace but not newlines
pattern = rf"\b([A-Z][a-zA-Z0-9&\-']*(?:[^\S\n]+(?:&[^\S\n]+)?[A-Z][a-zA-Z0-9&\-']*)*)[^\S\n]+({indicators_pattern})"
# Find all matches
for match in re.finditer(pattern, text):
company_name = match.group(1).strip()
indicator = match.group(2).strip()
# Remove leading conjunctions if present
words = company_name.split()
while words and words[0].lower() in ["and", "&", "or", "the"]:
words = words[1:]
if not words: # Skip if nothing left
continue
company_name = " ".join(words)
full_name = f"{company_name} {indicator}"
# Skip if it matches any skip conditions
if _should_skip_name(full_name):
continue
# Calculate confidence based on various factors
confidence = 0.8 # Base confidence for indicator match
confidence = _adjust_company_confidence(full_name, confidence)
results.append(
cast(
"CompanyExtractionResultTypedDict",
{
"name": full_name,
"confidence": confidence,
"sources": {"indicator_match"},
},
)
)
return results
def _extract_companies_by_patterns(text: str) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies using capitalization patterns."""
results: list[CompanyExtractionResultTypedDict] = []
# Pattern for capitalized multi-word phrases
# Matches: Apple Computer, General Electric Company, etc.
pattern = r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\b"
for match in re.finditer(pattern, text):
potential_name = match.group(1)
# Skip if it matches skip conditions
if _should_skip_name(potential_name):
continue
# Check if it might be a company based on context
confidence = 0.5 # Base confidence for pattern match
# Boost confidence if followed by company-like words
context_after = text[match.end() : match.end() + 50].lower()
if any(
word in context_after for word in ["announced", "reported", "said", "ceo", "president"]
):
confidence += 0.2
confidence = _adjust_company_confidence(potential_name, confidence)
results.append(
cast(
"CompanyExtractionResultTypedDict",
{
"name": potential_name,
"confidence": confidence,
"sources": {"pattern_match"},
},
)
)
return results
def _adjust_company_confidence(name: str, base_confidence: float) -> float:
"""Adjust confidence based on name characteristics."""
confidence = base_confidence
# Boost for certain keywords
boost_words = ["technologies", "corporation", "enterprises", "solutions", "systems"]
if any(word in name.lower() for word in boost_words):
confidence += 0.1
# Boost for proper length
word_count = len(name.split())
if 2 <= word_count <= 4:
confidence += 0.05
# Penalty for too many words
if word_count > 5:
confidence -= 0.2
return min(confidence, 0.95) # Cap at 0.95
def _should_skip_name(name: str) -> bool:
"""Check if a name should be skipped."""
name_lower = name.lower()
# Check skip phrases
if _contains_skip_phrase(name_lower):
return True
# Check if starts with article/preposition
if _starts_with_article_or_preposition(name):
return True
# Check if too short
if _is_too_short(name):
return True
# Check if too many numbers
if _has_too_many_numbers(name):
return True
# Skip common non-company words that might get picked up
skip_words = {
"this",
"that",
"these",
"those",
"other",
"another",
"every",
"each",
"all",
"some",
"many",
"few",
}
first_word = name.split()[0].lower()
return first_word in skip_words
def _contains_skip_phrase(name_lower: str) -> bool:
"""Check if name contains skip phrases."""
return any(phrase in name_lower for phrase in SKIP_PHRASES)
def _starts_with_article_or_preposition(name: str) -> bool:
"""Check if name starts with article or preposition."""
first_word = name.split()[0].lower()
return first_word in ARTICLES_PREPOSITIONS
def _is_too_short(name: str) -> bool:
"""Check if name is too short."""
return len(name.strip()) < 3
def _has_too_many_numbers(name: str) -> bool:
"""Check if name has too many numbers."""
digit_count = sum(c.isdigit() for c in name)
return digit_count > len(name) / 2
def _normalize_company_name(name: str) -> str:
"""Normalize company name for comparison."""
# Remove common suffixes
normalized = name.lower()
for suffix in COMMON_SUFFIXES:
normalized = normalized.replace(suffix.lower(), "")
# Remove extra whitespace and punctuation
normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = " ".join(normalized.split())
return normalized.strip()
def _handle_duplicate_company(
normalized_name: str,
company: CompanyExtractionResultTypedDict,
final_results: list[CompanyExtractionResultTypedDict],
) -> bool:
"""Handle potential duplicate company entries."""
for existing in final_results:
existing_normalized = _normalize_company_name(existing["name"])
if normalized_name == existing_normalized or _is_substantial_name_overlap(
normalized_name, existing_normalized
):
# Merge sources
if (
"sources" in existing
and "sources" in company
and isinstance(existing["sources"], set)
and isinstance(company["sources"], set)
):
existing["sources"].update(company["sources"])
# Update confidence if higher
if company["confidence"] > existing["confidence"]:
existing["confidence"] = company["confidence"]
return True
return False
def _is_substantial_name_overlap(name1: str, name2: str) -> bool:
"""Check if two names have substantial overlap."""
# Simple overlap check - can be made more sophisticated
words1 = set(name1.lower().split())
words2 = set(name2.lower().split())
# Remove common words
common_words = {"the", "and", "of", "in", "for"}
words1 -= common_words
words2 -= common_words
if not words1 or not words2:
return False
overlap = words1.intersection(words2)
overlap_ratio = len(overlap) / min(len(words1), len(words2))
return overlap_ratio >= 0.5
def _deduplicate_and_filter_results(
results: list[CompanyExtractionResultTypedDict], min_confidence: float
) -> list[CompanyExtractionResultTypedDict]:
"""Deduplicate and filter results by confidence."""
final_results: list[CompanyExtractionResultTypedDict] = []
# Sort by confidence (descending) to process higher confidence first
sorted_results = sorted(results, key=lambda x: x["confidence"], reverse=True)
for company in sorted_results:
if company["confidence"] < min_confidence:
continue
normalized_name = _normalize_company_name(company["name"])
if not _handle_duplicate_company(normalized_name, company, final_results):
final_results.append(company)
return final_results
# === Functions for specific extraction needs ===
def _extract_companies_from_single_result(
result: dict[str, str],
result_index: int,
known_companies: set[str] | None,
min_confidence: float,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies from a single search result."""
companies: list[CompanyExtractionResultTypedDict] = []
# Extract from title with higher confidence
title_companies = extract_company_names(
result.get("title", ""),
known_companies=known_companies,
min_confidence=min_confidence + 0.1,
)
for company in title_companies:
add_source_metadata("title", company, result_index, result.get("url", ""))
companies.append(company)
# Extract from snippet
snippet_companies = extract_company_names(
result.get("snippet", ""),
known_companies=known_companies,
min_confidence=min_confidence,
)
for company in snippet_companies:
# Check if not duplicate from title
is_duplicate = any(
_is_substantial_name_overlap(company["name"], tc["name"]) for tc in title_companies
)
if not is_duplicate:
add_source_metadata("snippet", company, result_index, result.get("url", ""))
companies.append(company)
return companies
def _update_existing_company(
new_company: CompanyExtractionResultTypedDict,
existing: CompanyExtractionResultTypedDict,
) -> None:
"""Update existing company with new information."""
# Update confidence (weighted average)
existing["confidence"] = (existing["confidence"] + new_company["confidence"]) / 2
# Merge sources
if (
"sources" in existing
and "sources" in new_company
and isinstance(existing["sources"], set)
and isinstance(new_company["sources"], set)
):
existing["sources"].update(new_company["sources"])
def _add_new_company(
company: CompanyExtractionResultTypedDict,
name_lower: str,
merged_results: dict[str, CompanyExtractionResultTypedDict],
) -> None:
"""Add new company to results."""
merged_results[name_lower] = company
def _merge_company_into_results(
company: CompanyExtractionResultTypedDict,
merged_results: dict[str, CompanyExtractionResultTypedDict],
) -> None:
"""Merge company into consolidated results."""
name_lower = company["name"].lower()
if name_lower in merged_results:
_update_existing_company(company, merged_results[name_lower])
else:
_add_new_company(company, name_lower, merged_results)
"""Company name extraction utilities.
This module provides functionality for extracting company names from text,
including pattern matching, confidence scoring, and deduplication.
"""
import re
from typing import cast
from ..core.extraction_patterns import (
ARTICLES_PREPOSITIONS,
COMMON_SUFFIXES,
COMPANY_INDICATORS,
SKIP_PHRASES,
)
from ..core.types import CompanyExtractionResultTypedDict
def extract_company_names(
text: str,
known_companies: set[str] | None = None,
min_confidence: float = 0.3,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract company names from text.
Args:
text: The text to extract company names from
known_companies: Optional set of known company names for validation
min_confidence: Minimum confidence threshold for results
Returns:
List of extracted company names with confidence scores
"""
if not text:
return []
results: list[CompanyExtractionResultTypedDict] = []
# Extract using company indicators (suffixes) first - these have higher confidence
indicator_companies = _extract_companies_by_indicators(text)
results.extend(indicator_companies)
# Extract using pattern matching (capitalization) - but exclude already found companies
# to avoid lower confidence duplicates
found_names = {comp.get("name", "").lower() for comp in indicator_companies if comp.get("name")}
pattern_companies = _extract_companies_by_patterns(text)
# Filter out companies already found by indicator matching
for comp in pattern_companies:
comp_name = comp.get("name")
if comp_name and comp_name.lower() not in found_names:
results.append(comp)
# Check known companies if provided
if known_companies:
for company in known_companies:
if company.lower() in text.lower():
results.append(
{
"name": company,
"confidence": 0.95,
"source": "known",
}
)
# Merge duplicates
merged: dict[str, CompanyExtractionResultTypedDict] = {}
for result in results:
name = result.get("name")
if not name:
continue
normalized = _normalize_company_name(name)
if not normalized: # Skip if normalization results in empty string
continue
if normalized not in merged or result["confidence"] > merged[normalized]["confidence"]:
merged[normalized] = result
# Convert to list and filter by confidence
final_results = list(merged.values())
return _deduplicate_and_filter_results(final_results, min_confidence)
def extract_companies_from_search_results(
search_results: list[dict[str, str]],
min_confidence: float = 0.3,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract company names from search results.
Args:
search_results: List of search result dicts with 'title' and 'snippet' keys
min_confidence: Minimum confidence threshold
Returns:
List of extracted company names with metadata
"""
all_companies: list[CompanyExtractionResultTypedDict] = []
for idx, result in enumerate(search_results):
# Extract from title
title_companies = extract_company_names(
result.get("title", ""),
min_confidence=min_confidence + 0.1, # Higher confidence for titles
)
for company in title_companies:
# Create new dict with additional fields since TypedDict is read-only
enriched_company = cast(
"CompanyExtractionResultTypedDict",
{
**company,
"url": result.get("url", ""),
"source_type": "title",
"source_idx": idx,
},
)
all_companies.append(enriched_company)
# Extract from snippet
snippet_companies = extract_company_names(
result.get("snippet", ""), min_confidence=min_confidence
)
for company in snippet_companies:
# Create new dict with additional fields since TypedDict is read-only
enriched_company = cast(
"CompanyExtractionResultTypedDict",
{
**company,
"url": result.get("url", ""),
"source_type": "snippet",
"source_idx": idx,
},
)
all_companies.append(enriched_company)
# Deduplicate across all results
return _deduplicate_and_filter_results(all_companies, min_confidence)
def add_source_metadata(
source_type: str,
company: CompanyExtractionResultTypedDict,
result_index: int,
url: str,
) -> None:
"""Add source metadata to company extraction result."""
company["sources"] = company.get("sources", set())
if isinstance(company["sources"], set):
company["sources"].add(f"{source_type}_{result_index}")
company["url"] = url
# === Private Helper Functions ===
def _extract_companies_by_indicators(
text: str,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies using suffix indicators like Inc., LLC, etc."""
results: list[CompanyExtractionResultTypedDict] = []
# COMPANY_INDICATORS already contains regex patterns, so don't escape them
indicators_pattern = f"({'|'.join(COMPANY_INDICATORS)})"
# Simpler pattern that's more reliable
# Captures: One or more capitalized words (including & between them) followed by a company indicator
# Use word boundary at the start to avoid matching mid-sentence
# Use [^\S\n] to match whitespace but not newlines
pattern = rf"\b([A-Z][a-zA-Z0-9&\-']*(?:[^\S\n]+(?:&[^\S\n]+)?[A-Z][a-zA-Z0-9&\-']*)*)[^\S\n]+({indicators_pattern})"
# Find all matches
for match in re.finditer(pattern, text):
company_name = match.group(1).strip()
indicator = match.group(2).strip()
# Remove leading conjunctions if present
words = company_name.split()
while words and words[0].lower() in ["and", "&", "or", "the"]:
words = words[1:]
if not words: # Skip if nothing left
continue
company_name = " ".join(words)
full_name = f"{company_name} {indicator}"
# Skip if it matches any skip conditions
if _should_skip_name(full_name):
continue
# Calculate confidence based on various factors
confidence = 0.8 # Base confidence for indicator match
confidence = _adjust_company_confidence(full_name, confidence)
results.append(
cast(
"CompanyExtractionResultTypedDict",
{
"name": full_name,
"confidence": confidence,
"sources": {"indicator_match"},
},
)
)
return results
def _extract_companies_by_patterns(text: str) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies using capitalization patterns."""
results: list[CompanyExtractionResultTypedDict] = []
# Pattern for capitalized multi-word phrases
# Matches: Apple Computer, General Electric Company, etc.
pattern = r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\b"
for match in re.finditer(pattern, text):
potential_name = match.group(1)
# Skip if it matches skip conditions
if _should_skip_name(potential_name):
continue
# Check if it might be a company based on context
confidence = 0.5 # Base confidence for pattern match
# Boost confidence if followed by company-like words
context_after = text[match.end() : match.end() + 50].lower()
if any(
word in context_after for word in ["announced", "reported", "said", "ceo", "president"]
):
confidence += 0.2
confidence = _adjust_company_confidence(potential_name, confidence)
results.append(
cast(
"CompanyExtractionResultTypedDict",
{
"name": potential_name,
"confidence": confidence,
"sources": {"pattern_match"},
},
)
)
return results
def _adjust_company_confidence(name: str, base_confidence: float) -> float:
"""Adjust confidence based on name characteristics."""
confidence = base_confidence
# Boost for certain keywords
boost_words = ["technologies", "corporation", "enterprises", "solutions", "systems"]
if any(word in name.lower() for word in boost_words):
confidence += 0.1
# Boost for proper length
word_count = len(name.split())
if 2 <= word_count <= 4:
confidence += 0.05
# Penalty for too many words
if word_count > 5:
confidence -= 0.2
return min(confidence, 0.95) # Cap at 0.95
def _should_skip_name(name: str) -> bool:
"""Check if a name should be skipped."""
name_lower = name.lower()
# Check skip phrases
if _contains_skip_phrase(name_lower):
return True
# Check if starts with article/preposition
if _starts_with_article_or_preposition(name):
return True
# Check if too short
if _is_too_short(name):
return True
# Check if too many numbers
if _has_too_many_numbers(name):
return True
# Skip common non-company words that might get picked up
skip_words = {
"this",
"that",
"these",
"those",
"other",
"another",
"every",
"each",
"all",
"some",
"many",
"few",
}
first_word = name.split()[0].lower()
return first_word in skip_words
def _contains_skip_phrase(name_lower: str) -> bool:
"""Check if name contains skip phrases."""
return any(phrase in name_lower for phrase in SKIP_PHRASES)
def _starts_with_article_or_preposition(name: str) -> bool:
"""Check if name starts with article or preposition."""
first_word = name.split()[0].lower()
return first_word in ARTICLES_PREPOSITIONS
def _is_too_short(name: str) -> bool:
"""Check if name is too short."""
return len(name.strip()) < 3
def _has_too_many_numbers(name: str) -> bool:
"""Check if name has too many numbers."""
digit_count = sum(c.isdigit() for c in name)
return digit_count > len(name) / 2
def _normalize_company_name(name: str) -> str:
"""Normalize company name for comparison."""
# Remove common suffixes
normalized = name.lower()
for suffix in COMMON_SUFFIXES:
normalized = normalized.replace(suffix.lower(), "")
# Remove extra whitespace and punctuation
normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = " ".join(normalized.split())
return normalized.strip()
def _handle_duplicate_company(
normalized_name: str,
company: CompanyExtractionResultTypedDict,
final_results: list[CompanyExtractionResultTypedDict],
) -> bool:
"""Handle potential duplicate company entries."""
for existing in final_results:
existing_normalized = _normalize_company_name(existing["name"])
if normalized_name == existing_normalized or _is_substantial_name_overlap(
normalized_name, existing_normalized
):
# Merge sources
if (
"sources" in existing
and "sources" in company
and isinstance(existing["sources"], set)
and isinstance(company["sources"], set)
):
existing["sources"].update(company["sources"])
# Update confidence if higher
if company["confidence"] > existing["confidence"]:
existing["confidence"] = company["confidence"]
return True
return False
def _is_substantial_name_overlap(name1: str, name2: str) -> bool:
"""Check if two names have substantial overlap."""
# Simple overlap check - can be made more sophisticated
words1 = set(name1.lower().split())
words2 = set(name2.lower().split())
# Remove common words
common_words = {"the", "and", "of", "in", "for"}
words1 -= common_words
words2 -= common_words
if not words1 or not words2:
return False
overlap = words1.intersection(words2)
overlap_ratio = len(overlap) / min(len(words1), len(words2))
return overlap_ratio >= 0.5
def _deduplicate_and_filter_results(
results: list[CompanyExtractionResultTypedDict], min_confidence: float
) -> list[CompanyExtractionResultTypedDict]:
"""Deduplicate and filter results by confidence."""
final_results: list[CompanyExtractionResultTypedDict] = []
# Sort by confidence (descending) to process higher confidence first
sorted_results = sorted(results, key=lambda x: x["confidence"], reverse=True)
for company in sorted_results:
if company["confidence"] < min_confidence:
continue
normalized_name = _normalize_company_name(company["name"])
if not _handle_duplicate_company(normalized_name, company, final_results):
final_results.append(company)
return final_results
# === Functions for specific extraction needs ===
def _extract_companies_from_single_result(
result: dict[str, str],
result_index: int,
known_companies: set[str] | None,
min_confidence: float,
) -> list[CompanyExtractionResultTypedDict]:
"""Extract companies from a single search result."""
companies: list[CompanyExtractionResultTypedDict] = []
# Extract from title with higher confidence
title_companies = extract_company_names(
result.get("title", ""),
known_companies=known_companies,
min_confidence=min_confidence + 0.1,
)
for company in title_companies:
add_source_metadata("title", company, result_index, result.get("url", ""))
companies.append(company)
# Extract from snippet
snippet_companies = extract_company_names(
result.get("snippet", ""),
known_companies=known_companies,
min_confidence=min_confidence,
)
for company in snippet_companies:
# Check if not duplicate from title
is_duplicate = any(
_is_substantial_name_overlap(company["name"], tc["name"]) for tc in title_companies
)
if not is_duplicate:
add_source_metadata("snippet", company, result_index, result.get("url", ""))
companies.append(company)
return companies
def _update_existing_company(
new_company: CompanyExtractionResultTypedDict,
existing: CompanyExtractionResultTypedDict,
) -> None:
"""Update existing company with new information."""
# Update confidence (weighted average)
existing["confidence"] = (existing["confidence"] + new_company["confidence"]) / 2
# Merge sources
if (
"sources" in existing
and "sources" in new_company
and isinstance(existing["sources"], set)
and isinstance(new_company["sources"], set)
):
existing["sources"].update(new_company["sources"])
def _add_new_company(
company: CompanyExtractionResultTypedDict,
name_lower: str,
merged_results: dict[str, CompanyExtractionResultTypedDict],
) -> None:
"""Add new company to results."""
merged_results[name_lower] = company
def _merge_company_into_results(
company: CompanyExtractionResultTypedDict,
merged_results: dict[str, CompanyExtractionResultTypedDict],
) -> None:
"""Merge company into consolidated results."""
name_lower = company["name"].lower()
if name_lower in merged_results:
_update_existing_company(company, merged_results[name_lower])
else:
_add_new_company(company, name_lower, merged_results)

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
import re
from bb_extraction import BaseExtractor, clean_text, extract_json_from_text
from bb_utils.core.unified_logging import get_logger
from ..core import BaseExtractor
from ..text import clean_text, extract_json_from_text
logger = get_logger(__name__)
@@ -48,9 +50,7 @@ class ComponentExtractor(BaseExtractor):
]
# Units and measurements to clean
self.measurement_pattern = (
r"\b\d+(?:\.\d+)?\s*(?:cups?|tbsp|tsp|oz|lb|g|kg|ml|l)\b"
)
self.measurement_pattern = r"\b\d+(?:\.\d+)?\s*(?:cups?|tbsp|tsp|oz|lb|g|kg|ml|l)\b"
def extract(
self, text: str
@@ -73,9 +73,7 @@ class ComponentExtractor(BaseExtractor):
json_data = extract_json_from_text(text)
if json_data:
# Check for both "ingredients" and "components" keys
components_data = json_data.get("components") or json_data.get(
"ingredients"
)
components_data = json_data.get("components") or json_data.get("ingredients")
if isinstance(components_data, list):
return self._process_json_components(components_data)
@@ -85,9 +83,7 @@ class ComponentExtractor(BaseExtractor):
# First try to extract from HTML component/ingredient sections
# Look for component/ingredient heading followed by list
html_pattern = r"<h[1-6][^>]*>[^<]*(?:ingredients?|components?|materials?|parts?\s+list)[^<]*</h[1-6]>.*?<ul[^>]*>(.*?)</ul>"
html_matches = re.findall(
html_pattern, original_text, re.DOTALL | re.IGNORECASE
)
html_matches = re.findall(html_pattern, original_text, re.DOTALL | re.IGNORECASE)
for match in html_matches:
extracted = self._extract_from_text_block(match)
components.extend(extracted)
@@ -165,26 +161,30 @@ class ComponentExtractor(BaseExtractor):
components = []
# Try different list patterns
items = []
items: list[str] = []
best_pattern_idx = -1
for i, pattern in enumerate(self.list_item_patterns):
matches = re.findall(pattern, text_block)
if matches and len(matches) > len(items):
# Use the pattern that gives us the most items
items = matches
# Ensure we have a list of strings (handle tuple results from capture groups)
if matches and isinstance(matches[0], tuple):
items = [match[0] if match else "" for match in matches]
else:
items = list(matches)
best_pattern_idx = i
if best_pattern_idx >= 0:
logger.debug(
f"Selected pattern {best_pattern_idx}: found {len(items)} items"
)
logger.debug(f"Selected pattern {best_pattern_idx}: found {len(items)} items")
# If no pattern matched, split by newlines
if not items:
items = [line.strip() for line in text_block.split("\n") if line.strip()]
# Process each item
for item in items:
# Ensure items is properly typed
items_list: list[str] = items if isinstance(items, list) else []
for item in items_list:
# Check if this item contains multiple ingredients separated by commas
# e.g., "curry powder, ginger powder, allspice"
if "," in item and not any(char.isdigit() for char in item[:5]):
@@ -276,31 +276,30 @@ class ComponentExtractor(BaseExtractor):
"""
components = []
if isinstance(components_data, list):
for item in components_data:
if isinstance(item, dict):
components.append(
{
"name": item.get(
"name",
item.get("component", item.get("ingredient", "")),
),
"quantity": item.get("quantity"),
"unit": item.get("unit"),
"confidence": 0.95, # Higher confidence for structured data
"raw_data": item,
}
)
elif isinstance(item, str):
components.append(
{
"name": self._clean_component(item),
"quantity": None,
"unit": None,
"confidence": 0.9,
"raw_data": {"raw_text": item},
}
)
for item in components_data:
if isinstance(item, dict):
components.append(
{
"name": item.get(
"name",
item.get("component", item.get("ingredient", "")),
),
"quantity": item.get("quantity"),
"unit": item.get("unit"),
"confidence": 0.95, # Higher confidence for structured data
"raw_data": item,
}
)
else:
components.append(
{
"name": self._clean_component(item),
"quantity": None,
"unit": None,
"confidence": 0.9,
"raw_data": {"raw_text": item},
}
)
return components

View File

@@ -22,7 +22,7 @@ import re
from dataclasses import dataclass
from re import Pattern
from types import SimpleNamespace
from typing import Literal, cast
from typing import Any, Literal, cast
from ..core.base import BaseExtractor
from ..core.types import ActionArgsDict, JsonDict, JsonValue
@@ -200,7 +200,7 @@ def parse_action_args(args_str: str) -> "ActionArgsDict":
return ActionArgsDict(**hardcoded_result)
# Regular parsing logic for other cases
args_dict: ActionArgsDict = {}
args_dict: dict[str, Any] = {}
pattern = r'(\w+)=(["\'])((?:\\.|[^\\"])*?)\2(?:,|$)|(\w+)=([^,]+)(?:,|$)'
matches = re.finditer(pattern, args_str)

View File

@@ -11,7 +11,7 @@ from typing import Any, cast
from bs4 import BeautifulSoup
from ..core.extraction_patterns import STOPWORDS
from ..core.types import JsonDict, JsonValue
from ..core.types import MetadataExtraction
def extract_title_from_text(text: str) -> str | None:
@@ -69,7 +69,7 @@ def extract_keywords_from_text(text: str, max_keywords: int = 10) -> list[str]:
return [word for word, _ in sorted_words[:max_keywords]]
async def extract_metadata(text: str) -> JsonDict:
async def extract_metadata(text: str) -> MetadataExtraction:
"""Extract metadata from text including title, description, keywords, author, and date."""
title = extract_title_from_text(text)
# Extract description as text after the first blank line, if present
@@ -91,10 +91,10 @@ async def extract_metadata(text: str) -> JsonDict:
date_match = re.search(r"\b(\w+\s+\d{1,2},?\s+\d{4})\b", text)
if date_match:
date = date_match.group(1)
result: JsonDict = {
result: MetadataExtraction = {
"title": title,
"description": description,
"keywords": cast("JsonValue", keywords), # list[str] is a valid JsonValue
"keywords": keywords,
"author": author,
"date": date,
}
@@ -146,7 +146,7 @@ class MetadataExtractor:
continue
# Cast to Any to work around pyrefly limitation
meta_tag = cast(Any, meta)
meta_tag = cast("Any", meta)
# BeautifulSoup returns various types from get(), ensure we have strings
name_val = meta_tag.get("name", "")
@@ -176,7 +176,7 @@ class MetadataExtractor:
# Language
html_tag = soup.find("html")
if html_tag and hasattr(html_tag, "get") and hasattr(html_tag, "name"):
html_elem = cast(Any, html_tag)
html_elem = cast("Any", html_tag)
lang = html_elem.get("lang")
if lang:
metadata.language = str(lang)
@@ -184,7 +184,7 @@ class MetadataExtractor:
# Canonical URL
canonical = soup.find("link", rel="canonical")
if canonical and hasattr(canonical, "get") and hasattr(canonical, "name"):
canonical_elem = cast(Any, canonical)
canonical_elem = cast("Any", canonical)
href = canonical_elem.get("href")
if href:
metadata.source_url = str(href)

View File

@@ -1,15 +1,14 @@
"""Extract statistics from text content."""
import logging
import re
from datetime import UTC, datetime
from re import Pattern
from bb_utils.core.unified_logging import get_logger
from .models import ExtractedStatistic, StatisticType
from .quality import assess_quality
logger = get_logger(__name__)
logger = logging.getLogger(__name__)
class StatisticsExtractor:

View File

@@ -8,7 +8,7 @@ import ast
import contextlib
import json
import re
from typing import cast
from typing import Any, cast
from ..core.extraction_patterns import (
JSON_BRACES_PATTERN,
@@ -29,10 +29,11 @@ def extract_json_from_text(text: str) -> JsonDict | None:
logger.warning(f"Text starts with: {repr(text[:100])}")
# Pre-process text to handle common LLM response patterns
text = text.strip()
# Ensure text is properly typed as str with explicit casting
processed_text: str = str(text).strip()
# Remove common prefixes that LLMs might add
prefixes_to_remove = [
prefixes_to_remove: list[str] = [
"Valid JSON Output:",
"Here is the JSON:",
"JSON Output:",
@@ -40,12 +41,14 @@ def extract_json_from_text(text: str) -> JsonDict | None:
"```",
]
for prefix in prefixes_to_remove:
if text.startswith(prefix):
text = text[len(prefix) :].strip()
prefix_str = str(prefix)
text_str: str = processed_text
if text_str.startswith(prefix_str):
processed_text = text_str[len(prefix_str) :].strip()
# Remove trailing markdown code block marker if present
if text.endswith("```"):
text = text[:-3].strip()
if processed_text.endswith("```"):
processed_text = processed_text[:-3].strip()
# Fix common JSON formatting issues from LLMs
# Replace literal newlines within JSON strings with escaped newlines
@@ -86,16 +89,16 @@ def extract_json_from_text(text: str) -> JsonDict | None:
return json_text
# Apply the fix
original_text = text
text = fix_json_newlines(text)
original_text = processed_text
processed_text = fix_json_newlines(processed_text)
# If the text was modified, log it
if text != original_text:
if processed_text != original_text:
logger.debug("JSON text was modified to fix newlines")
# First, try to parse the entire text as JSON (most direct approach)
try:
result = json.loads(text)
result = json.loads(processed_text)
if isinstance(result, dict):
logger.debug("Successfully parsed entire text as JSON")
return result
@@ -103,7 +106,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
logger.debug(f"Direct JSON parse failed: {e}")
# First try to find JSON in code blocks
if matches := JSON_CODE_BLOCK_PATTERN.findall(text):
if matches := JSON_CODE_BLOCK_PATTERN.findall(processed_text):
logger.debug(f"Found {len(matches)} JSON code block matches")
for match in matches:
try:
@@ -122,7 +125,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
continue
# Try the improved JSON object pattern
if matches := JSON_OBJECT_PATTERN.findall(text):
if matches := JSON_OBJECT_PATTERN.findall(processed_text):
logger.debug(f"Found {len(matches)} JSON object pattern matches")
for match in matches:
try:
@@ -141,7 +144,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
continue
# Then try to find JSON objects directly in the text with basic pattern
if matches := JSON_BRACES_PATTERN.findall(text):
if matches := JSON_BRACES_PATTERN.findall(processed_text):
logger.debug(f"Found {len(matches)} JSON braces pattern matches")
for match in matches:
try:
@@ -161,7 +164,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
# Try to extract just the JSON object if it's embedded in text
# Look for the first '{' and try to parse from there
first_brace = text.find("{")
first_brace = processed_text.find("{")
if first_brace != -1:
# Try parsing from the first brace
try:
@@ -171,8 +174,8 @@ def extract_json_from_text(text: str) -> JsonDict | None:
escape_next = False
end_pos = first_brace
for i in range(first_brace, len(text)):
char = text[i]
for i in range(first_brace, len(processed_text)):
char = processed_text[i]
if escape_next:
escape_next = False
@@ -196,7 +199,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
break
if end_pos > first_brace:
json_substring = text[first_brace:end_pos]
json_substring = processed_text[first_brace:end_pos]
result = json.loads(json_substring)
if isinstance(result, dict):
logger.debug("Successfully extracted JSON by finding balanced braces")
@@ -205,15 +208,15 @@ def extract_json_from_text(text: str) -> JsonDict | None:
logger.debug(f"Failed to parse JSON substring: {e}")
# Special case: if text starts with '{' but is truncated or incomplete
if text.strip().startswith("{"):
if processed_text.strip().startswith("{"):
# First try to parse as-is
try:
result = json.loads(text.strip())
result = json.loads(processed_text.strip())
if isinstance(result, dict):
return result
except json.JSONDecodeError:
# Try to fix it
fixed_text = _fix_truncated_json(text.strip())
fixed_text = _fix_truncated_json(processed_text.strip())
if fixed_text:
try:
result = json.loads(fixed_text)
@@ -225,7 +228,7 @@ def extract_json_from_text(text: str) -> JsonDict | None:
# Last resort: try to parse the entire text as JSON
try:
# Remove any leading/trailing whitespace or quotes
cleaned_text = text.strip().strip("\"'")
cleaned_text = processed_text.strip().strip("\"'")
result = json.loads(cleaned_text)
if isinstance(result, dict):
return result
@@ -384,7 +387,7 @@ def extract_structured_data(text: str) -> StructuredExtractionResult:
if evaluated is not None and isinstance(
evaluated, str | int | float | bool | list | dict
):
result["evaluated"] = evaluated
result["evaluated"] = cast("JsonValue", evaluated)
return result
@@ -446,7 +449,7 @@ def parse_action_args(text: str) -> ActionArgsDict:
json_data = extract_json_from_text(text)
if json_data and isinstance(json_data, dict):
# Convert to ActionArgsDict format
result: ActionArgsDict = {}
result: dict[str, Any] = {}
for k, v in json_data.items():
if isinstance(v, str | int | float | bool):
result[k] = v
@@ -454,15 +457,15 @@ def parse_action_args(text: str) -> ActionArgsDict:
result[k] = cast("list[str]", v)
elif isinstance(v, dict) and all(isinstance(val, str) for val in v.values()):
result[k] = cast("dict[str, str]", v)
return result
return ActionArgsDict(**result)
# Try key-value pairs
if kvs := extract_key_value_pairs(text):
# Convert dict[str, str] to ActionArgsDict
result: ActionArgsDict = {}
kv_result: ActionArgsDict = {}
for k, v in kvs.items():
result[k] = v
return result
kv_result[k] = v
return kv_result
# Try Python dict literal
literal_result = safe_literal_eval(text)

View File

@@ -5,12 +5,13 @@ supporting integration with agent frameworks.
"""
import json
from typing import cast
import logging
from typing import Annotated, Any, cast
from bb_core.validation import chunk_text, is_valid_url, merge_chunk_results
from bb_utils.cache.cache_decorator import cache
from bb_utils.core.log_config import error_highlight, info_highlight
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from .core.types import FactTypedDict, JsonDict, JsonValue
from .numeric.numeric import (
@@ -24,74 +25,95 @@ from .numeric.quality import (
rate_statistic_quality,
)
logger = logging.getLogger(__name__)
class StatisticsExtractionTool:
"""Tool for extracting statistics from text with enhanced metadata."""
name = "statistics_extraction"
description = "Extract statistics and numerical data from text with quality scoring"
class StatisticsExtractionInput(BaseModel):
"""Input schema for statistics extraction."""
async def _arun(self, text: str, **kwargs: str) -> str:
"""Async extraction - delegates to sync version."""
return self.run(text=text, **kwargs)
text: str = Field(description="The text to extract statistics from")
url: str | None = Field(default=None, description="Source URL for quality assessment")
source_title: str | None = Field(default=None, description="Source title for context")
chunk_size: Annotated[int, Field(ge=100, le=50000)] = Field(
default=8000, description="Size for text chunking"
)
def run(self, text: str, **kwargs: str) -> str:
"""Extract statistics from text.
Args:
text: The text to extract statistics from
**kwargs: Additional metadata (url, source_title, etc.)
class StatisticsExtractionOutput(BaseModel):
"""Output schema for statistics extraction."""
Returns:
JSON string with extracted statistics
"""
try:
# Extract various types of statistics
percentages = extract_percentages(text)
monetary = extract_monetary_values(text)
statistics: list[dict[str, Any]] = Field(description="Extracted statistics with metadata")
quality_scores: dict[str, float] = Field(description="Quality assessment scores")
total_facts: int = Field(description="Total number of facts extracted")
# Combine and enrich results
all_stats = []
for stat in percentages:
fact: FactTypedDict = {
"text": stat.text,
"type": "percentage",
"value": stat.value,
"scale": stat.scale,
"currency": getattr(stat, "currency", ""),
"quality_score": rate_statistic_quality(stat.text),
"credibility_terms": extract_credibility_terms(stat.text),
"year_mentioned": extract_year(stat.text),
"source_quality": assess_source_quality(text),
}
all_stats.append(fact)
@tool("statistics_extraction", args_schema=StatisticsExtractionInput, return_direct=False)
def extract_statistics(
text: str,
url: str | None = None,
source_title: str | None = None,
chunk_size: int = 8000,
config: RunnableConfig | None = None,
) -> dict[str, Any]:
"""Extract statistics and numerical data from text with quality scoring.
for stat in monetary:
monetary_fact: FactTypedDict = {
"text": stat.text,
"type": "monetary",
"value": stat.value,
"scale": stat.scale,
"currency": getattr(stat, "currency", "USD"),
"quality_score": rate_statistic_quality(stat.text),
"credibility_terms": extract_credibility_terms(stat.text),
"year_mentioned": extract_year(stat.text),
"source_quality": assess_source_quality(text),
}
all_stats.append(monetary_fact)
Args:
text: The text to extract statistics from
url: Source URL for quality assessment
source_title: Source title for context
chunk_size: Size for text chunking
config: Optional RunnableConfig
# Add metadata from kwargs
for fact in all_stats:
if "url" in kwargs:
fact["source_url"] = kwargs["url"]
if "source_title" in kwargs:
fact["source_title"] = kwargs["source_title"]
Returns:
Dictionary containing extracted statistics with quality scores
"""
try:
# Extract various types of statistics
percentages = extract_percentages(text)
monetary = extract_monetary_values(text)
return json.dumps({"statistics": all_stats}, indent=2)
# Combine and enrich results
all_stats = []
except Exception as e:
return json.dumps({"error": str(e), "statistics": []})
for stat in percentages:
fact: FactTypedDict = {
"text": stat.text,
"type": "percentage",
"value": stat.value,
"scale": stat.scale,
"currency": getattr(stat, "currency", ""),
"quality_score": rate_statistic_quality(stat.text),
"credibility_terms": extract_credibility_terms(stat.text),
"year_mentioned": extract_year(stat.text),
"source_quality": assess_source_quality(text),
}
all_stats.append(fact)
for stat in monetary:
monetary_fact: FactTypedDict = {
"text": stat.text,
"type": "monetary",
"value": stat.value,
"scale": stat.scale,
"currency": getattr(stat, "currency", "USD"),
"quality_score": rate_statistic_quality(stat.text),
"credibility_terms": extract_credibility_terms(stat.text),
"year_mentioned": extract_year(stat.text),
"source_quality": assess_source_quality(text),
}
all_stats.append(monetary_fact)
# Add metadata from function parameters
for fact in all_stats:
if url:
fact["source_url"] = url
if source_title:
fact["source_title"] = source_title
return {"statistics": all_stats, "quality_scores": {}, "total_facts": len(all_stats)}
except Exception as e:
return {"error": str(e), "statistics": [], "quality_scores": {}, "total_facts": 0}
class CategoryExtractionTool:
@@ -130,7 +152,7 @@ class CategoryExtractionTool:
raise NotImplementedError("Use async version (_arun) for this tool")
@cache(ttl=3600)
# Cache decorator removed to avoid bb_utils dependency
async def extract_category_information(
content: str,
url: str,
@@ -153,11 +175,11 @@ async def extract_category_information(
try:
# Validate inputs
if not content or not content.strip():
info_highlight("Empty content provided")
logger.info("Empty content provided")
return _default_extraction_result(category)
if not is_valid_url(url):
info_highlight(f"Invalid URL: {url}")
logger.info(f"Invalid URL: {url}")
return _default_extraction_result(category)
# Process content
@@ -170,7 +192,7 @@ async def extract_category_information(
)
except Exception as e:
error_highlight(f"Error in extract_category_information: {e}")
logger.error(f"Error in extract_category_information: {e}")
return _default_extraction_result(category)

View File

@@ -5,16 +5,18 @@ from pathlib import Path
import pytest
# Import all fixtures from helpers to make them globally available
from .helpers.assertions import * # noqa: F403 E402
from .helpers.factories import * # noqa: F403 E402
from .helpers.fixtures.extraction_fixtures import * # noqa: F403 E402
from .helpers.fixtures.mock_fixtures import * # noqa: F403 E402
from .helpers.fixtures.text_fixtures import * # noqa: F403 E402
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
# Add tests directory to path for helpers module
sys.path.insert(0, str(Path(__file__).parent))
# Import all fixtures from helpers to make them globally available
from helpers.assertions import * # noqa: F403 E402
from helpers.factories import * # noqa: F403 E402
from helpers.fixtures.extraction_fixtures import * # noqa: F403 E402
from helpers.fixtures.mock_fixtures import * # noqa: F403 E402
from helpers.fixtures.text_fixtures import * # noqa: F403 E402
# Global test configuration
@@ -34,10 +36,7 @@ def test_data_dir() -> Path:
return Path(__file__).parent / "data"
@pytest.fixture(scope="session")
def temp_dir(tmp_path_factory) -> Path:
"""Provide a session-wide temporary directory."""
return tmp_path_factory.mktemp("extraction_tests")
# temp_dir fixture moved to root conftest.py (session-scoped version available as temp_dir_session)
# Test environment configuration
@@ -50,30 +49,7 @@ def setup_test_environment(monkeypatch):
# Performance monitoring fixtures
@pytest.fixture
def benchmark_timer():
"""Simple timer for performance benchmarking."""
import time
class Timer:
def __init__(self):
self.start_time = None
self.end_time = None
def __enter__(self):
self.start_time = time.time()
return self
def __exit__(self, *args):
self.end_time = time.time()
@property
def elapsed(self) -> float:
if self.start_time and self.end_time:
return self.end_time - self.start_time
return 0.0
return Timer
# benchmark_timer fixture moved to root conftest.py
# Mock benchmark fixture for performance tests
@@ -101,7 +77,7 @@ def benchmark():
if kwargs is None:
kwargs = {}
total_time = 0
total_time = 0.0
for _ in range(rounds):
start = time.time()
for _ in range(iterations):
@@ -115,14 +91,7 @@ def benchmark():
# Async test support
@pytest.fixture
def event_loop():
"""Create an event loop for async tests."""
import asyncio
loop = asyncio.new_event_loop()
yield loop
loop.close()
# event_loop fixture replaced by centralized event_loop_policy in root conftest.py
# Test data validation
@@ -130,7 +99,7 @@ def event_loop():
def validate_extraction_result():
"""Provide a validation function for extraction results."""
def validator(result: dict, required_fields: list[str] | None = None) -> bool:
def validator(result: dict[str, object], required_fields: list[str] | None = None) -> bool:
"""Validate extraction result structure.
Args:

View File

@@ -27,9 +27,10 @@ def assert_company_extraction_valid(
assert "confidence" in result, "Company extraction must have a confidence"
if expected_name:
result_name = result.get("name", "")
assert (
expected_name.lower() in result["name"].lower()
), f"Expected '{expected_name}' in company name, got '{result['name']}'"
result_name and expected_name.lower() in result_name.lower()
), f"Expected '{expected_name}' in company name, got '{result_name}'"
assert (
result["confidence"] >= min_confidence

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
import pytest
from ..factories import (
from helpers.factories import (
CompanyExtractionFactory,
ExtractionResultFactory,
MetadataFactory,

View File

@@ -12,8 +12,7 @@ from bb_extraction import (
extract_monetary_values,
extract_percentages,
)
from ..helpers.assertions import (
from helpers.assertions import (
assert_company_extraction_valid,
assert_json_extraction_valid,
assert_metadata_valid,
@@ -62,6 +61,9 @@ class TestFullExtractionWorkflow:
# Extract JSON
json_data = extract_json_from_text(json_embedded_text)
# Ensure extraction was successful
assert json_data is not None, "JSON extraction returned None"
# Validate extraction
assert_json_extraction_valid(
json_data,
@@ -70,11 +72,13 @@ class TestFullExtractionWorkflow:
# Process extracted data
assert json_data["status"] == "success"
assert "company" in json_data["data"]
assert "metrics" in json_data["data"]
data = json_data["data"]
assert isinstance(data, dict)
assert "company" in data
assert "metrics" in data
# Extract companies from the JSON content
company_text = str(json_data["data"].get("company", ""))
company_text = str(data.get("company", ""))
if company_text:
extract_company_names(company_text)
# Company name might not have standard suffix, so check the value
@@ -149,15 +153,17 @@ class TestFullExtractionWorkflow:
assert_metadata_valid(metadata, has_title=True, has_keywords=True)
# Check specific values
assert "Technology Trends" in metadata.get("title", "")
title = metadata.get("title", "")
assert isinstance(title, str) and "Technology Trends" in title
assert metadata.get("author") == "Dr. Sarah Johnson"
assert metadata.get("date") == "2024-03-15"
# Keywords should be relevant
keywords = metadata["keywords"]
assert isinstance(keywords, list)
expected_keywords = ["AI", "machine learning", "cloud", "cybersecurity"]
for expected in expected_keywords:
assert any(expected.lower() in k.lower() for k in keywords)
assert any(expected.lower() in k.lower() for k in keywords if isinstance(k, str))
@pytest.mark.integration
@pytest.mark.slow

View File

@@ -28,8 +28,8 @@ def test_entity_extraction_result_structure() -> None:
"""Test EntityExtractionResult TypedDict structure."""
entity: EntityExtractionResult = {
"entities": [
{"text": "Apple", "type": "ORG", "confidence": 0.95},
{"text": "Tim Cook", "type": "PERSON", "confidence": 0.9},
{"name": "Apple", "type": "ORG", "confidence": 0.95},
{"name": "Tim Cook", "type": "PERSON", "confidence": 0.9},
],
"relationships": [
{"source": "Tim Cook", "target": "Apple", "type": "CEO_OF"},
@@ -37,7 +37,7 @@ def test_entity_extraction_result_structure() -> None:
"confidence": 0.92,
}
assert len(entity["entities"]) == 2
assert entity["entities"][0]["text"] == "Apple"
assert entity["entities"][0]["name"] == "Apple"
assert entity["entities"][0]["type"] == "ORG"
assert len(entity["relationships"]) == 1
assert entity["confidence"] == 0.92

View File

@@ -1,137 +1,137 @@
"""Tests for company extraction functionality."""
from typing import TYPE_CHECKING
import pytest
from bb_extraction import (
add_source_metadata,
extract_companies_from_search_results,
extract_company_names,
)
if TYPE_CHECKING:
from bb_extraction import CompanyExtractionResultTypedDict
def test_extract_company_names_basic() -> None:
"""Test basic company name extraction."""
text = "Acme Inc. announced today. Beta Corporation also joined."
results = extract_company_names(text)
assert len(results) >= 1
company_names = [r["name"] for r in results]
assert any("Acme Inc" in name for name in company_names)
def test_extract_company_names_with_indicators() -> None:
"""Test extraction with company indicators."""
text = "Apple Corporation and Microsoft Technologies are tech giants."
results = extract_company_names(text)
company_names = [r["name"] for r in results]
assert any("Apple Corporation" in name for name in company_names)
assert any("Microsoft Technologies" in name for name in company_names)
def test_extract_company_names_deduplication() -> None:
"""Test that duplicate company names are deduplicated."""
text = "Acme Inc. is great. Acme Inc. is really great. ACME INC. rocks!"
results = extract_company_names(text)
acme_results = [r for r in results if "Acme" in r["name"] or "ACME" in r["name"]]
# Should be deduplicated
assert len(acme_results) == 1
def test_extract_company_names_confidence() -> None:
"""Test confidence scoring."""
text = "Acme Inc. is a company."
results = extract_company_names(text)
acme_result = next(r for r in results if "Acme Inc" in r["name"])
# Higher confidence due to suffix and indicator
assert acme_result["confidence"] >= 0.8
def test_extract_companies_from_search_results() -> None:
"""Test extraction from search results."""
search_results = [
{
"title": "Acme Inc. - Leading Innovation",
"snippet": "Acme Inc. is a technology company focused on AI.",
"url": "https://acme.com",
},
{
"title": "Beta LLC Financial Report",
"snippet": "Beta LLC reported strong earnings this quarter.",
"url": "https://beta.com",
},
]
results = extract_companies_from_search_results(search_results)
assert len(results) >= 2
company_names = [r["name"] for r in results]
assert any("Acme Inc" in name for name in company_names)
assert any("Beta LLC" in name for name in company_names)
# Check metadata
acme_result = next(r for r in results if "Acme Inc" in r["name"])
assert "url" in acme_result
assert acme_result["url"] == "https://acme.com"
def test_extract_companies_empty_results() -> None:
"""Test extraction with empty search results."""
results = extract_companies_from_search_results([])
assert results == []
def test_add_source_metadata() -> None:
"""Test adding source metadata to company info."""
company: CompanyExtractionResultTypedDict = {"name": "Acme Inc.", "confidence": 0.9}
add_source_metadata("search", company, 0, "https://example.com")
assert company["name"] == "Acme Inc."
assert company["confidence"] == 0.9
assert "sources" in company
assert "search_0" in company["sources"]
assert company["url"] == "https://example.com"
def test_add_source_metadata_multiple_sources() -> None:
"""Test adding multiple sources to company metadata."""
company: CompanyExtractionResultTypedDict = {
"name": "Beta LLC",
"confidence": 0.8,
"sources": {"indicator_match"},
}
add_source_metadata("search", company, 1, "https://beta.com")
assert "sources" in company
assert "indicator_match" in company["sources"]
assert "search_1" in company["sources"]
assert company["url"] == "https://beta.com"
@pytest.mark.asyncio
async def test_company_extraction_from_documents(sample_documents):
"""Test extraction of company names from sample documents."""
try:
from bb_extraction.domain.entity_extraction import extract_companies
except ImportError:
# If extract_companies doesn't exist in entity_extraction, skip
pytest.skip("extract_companies not available in entity_extraction")
companies = []
for doc in sample_documents:
extracted = await extract_companies(doc["content"])
companies.extend(extracted)
# Should find Apple, Microsoft, Federal Reserve
assert len(companies) >= 2
assert any("Apple" in getattr(c, "name", "") for c in companies)
assert any("Microsoft" in getattr(c, "name", "") for c in companies)
"""Tests for company extraction functionality."""
from typing import TYPE_CHECKING
import pytest
from bb_extraction import (
add_source_metadata,
extract_companies_from_search_results,
extract_company_names,
)
if TYPE_CHECKING:
from bb_extraction import CompanyExtractionResultTypedDict
def test_extract_company_names_basic() -> None:
"""Test basic company name extraction."""
text = "Acme Inc. announced today. Beta Corporation also joined."
results = extract_company_names(text)
assert len(results) >= 1
company_names = [r["name"] for r in results]
assert any("Acme Inc" in name for name in company_names)
def test_extract_company_names_with_indicators() -> None:
"""Test extraction with company indicators."""
text = "Apple Corporation and Microsoft Technologies are tech giants."
results = extract_company_names(text)
company_names = [r["name"] for r in results]
assert any("Apple Corporation" in name for name in company_names)
assert any("Microsoft Technologies" in name for name in company_names)
def test_extract_company_names_deduplication() -> None:
"""Test that duplicate company names are deduplicated."""
text = "Acme Inc. is great. Acme Inc. is really great. ACME INC. rocks!"
results = extract_company_names(text)
acme_results = [r for r in results if "Acme" in r.get("name", "") or "ACME" in r["name"]]
# Should be deduplicated
assert len(acme_results) == 1
def test_extract_company_names_confidence() -> None:
"""Test confidence scoring."""
text = "Acme Inc. is a company."
results = extract_company_names(text)
acme_result = next(r for r in results if "Acme Inc" in r.get("name", ""))
# Higher confidence due to suffix and indicator
assert acme_result["confidence"] >= 0.8
def test_extract_companies_from_search_results() -> None:
"""Test extraction from search results."""
search_results = [
{
"title": "Acme Inc. - Leading Innovation",
"snippet": "Acme Inc. is a technology company focused on AI.",
"url": "https://acme.com",
},
{
"title": "Beta LLC Financial Report",
"snippet": "Beta LLC reported strong earnings this quarter.",
"url": "https://beta.com",
},
]
results = extract_companies_from_search_results(search_results)
assert len(results) >= 2
company_names = [r["name"] for r in results]
assert any("Acme Inc" in name for name in company_names)
assert any("Beta LLC" in name for name in company_names)
# Check metadata
acme_result = next(r for r in results if "Acme Inc" in r.get("name", ""))
assert "url" in acme_result
assert acme_result["url"] == "https://acme.com"
def test_extract_companies_empty_results() -> None:
"""Test extraction with empty search results."""
results = extract_companies_from_search_results([])
assert results == []
def test_add_source_metadata() -> None:
"""Test adding source metadata to company info."""
company: CompanyExtractionResultTypedDict = {"name": "Acme Inc.", "confidence": 0.9}
add_source_metadata("search", company, 0, "https://example.com")
assert company["name"] == "Acme Inc."
assert company["confidence"] == 0.9
assert "sources" in company
assert "search_0" in company["sources"]
assert company["url"] == "https://example.com"
def test_add_source_metadata_multiple_sources() -> None:
"""Test adding multiple sources to company metadata."""
company: CompanyExtractionResultTypedDict = {
"name": "Beta LLC",
"confidence": 0.8,
"sources": {"indicator_match"},
}
add_source_metadata("search", company, 1, "https://beta.com")
assert "sources" in company
assert "indicator_match" in company["sources"]
assert "search_1" in company["sources"]
assert company["url"] == "https://beta.com"
@pytest.mark.asyncio
async def test_company_extraction_from_documents(sample_documents):
"""Test extraction of company names from sample documents."""
try:
from bb_extraction.domain.entity_extraction import extract_companies
except ImportError:
# If extract_companies doesn't exist in entity_extraction, skip
pytest.skip("extract_companies not available in entity_extraction")
companies = []
for doc in sample_documents:
extracted = await extract_companies(doc["content"])
companies.extend(extracted)
# Should find Apple, Microsoft, Federal Reserve
assert len(companies) >= 2
assert any("Apple" in getattr(c, "name", "") for c in companies)
assert any("Microsoft" in getattr(c, "name", "") for c in companies)

View File

@@ -9,8 +9,7 @@ from bb_extraction.domain.company_extraction import (
extract_companies_from_search_results,
extract_company_names,
)
from ...helpers.assertions import assert_company_extraction_valid
from helpers.assertions import assert_company_extraction_valid
if TYPE_CHECKING:
from bb_extraction.core.types import CompanyExtractionResultTypedDict
@@ -86,7 +85,7 @@ class TestCompanyExtraction:
results = extract_company_names(text)
# Should deduplicate to one Microsoft entry
microsoft_results = [r for r in results if "Microsoft" in r["name"]]
microsoft_results = [r for r in results if "Microsoft" in r.get("name", "")]
assert len(microsoft_results) == 1
@pytest.mark.unit
@@ -172,8 +171,8 @@ class TestCompanyExtraction:
if expected_boost and known_companies:
# Apple should have higher confidence than RandomStartup123
apple_result = next((r for r in results if "Apple" in r["name"]), None)
other_result = next((r for r in results if "RandomStartup" in r["name"]), None)
apple_result = next((r for r in results if "Apple" in r.get("name", "")), None)
other_result = next((r for r in results if "RandomStartup" in r.get("name", "")), None)
if apple_result and other_result:
assert apple_result["confidence"] > other_result["confidence"]

View File

@@ -73,8 +73,10 @@ async def test_extract_metadata_comprehensive() -> None:
assert metadata["title"] == "Document Title"
assert metadata["description"] is not None
assert len(metadata["keywords"]) > 0
assert "python" in metadata["keywords"]
keywords = metadata["keywords"]
assert isinstance(keywords, list)
assert len(keywords) > 0
assert "python" in keywords
assert metadata["author"] is None # No author info in text
assert metadata["date"] is None # No date info in text
@@ -89,7 +91,9 @@ async def test_metadata_extraction_from_documents(sample_documents):
assert "title" in metadata
assert "description" in metadata
assert "keywords" in metadata
assert len(metadata["keywords"]) > 0
keywords = metadata["keywords"]
assert isinstance(keywords, list)
assert len(keywords) > 0
assert "author" in metadata
# Authors are actually present in the sample documents
if "John Smith" in doc["content"]:

View File

@@ -80,7 +80,7 @@ class TestStatisticsExtraction:
("2.5k users", (2.5, "thousand")),
],
)
def test_normalize_number(self, text: str, expected: tuple):
def test_normalize_number(self, text: str, expected: tuple[float, str]):
"""Test number normalization."""
value, scale = normalize_number(text)
expected_value, expected_scale = expected

Some files were not shown because too many files have changed in this diff Show More