diff --git a/.github/agents/sage-apps.agent.md b/.github/agents/sage-apps.agent.md new file mode 100644 index 0000000..3ebf3d4 --- /dev/null +++ b/.github/agents/sage-apps.agent.md @@ -0,0 +1,31 @@ +______________________________________________________________________ + +## name: sage-examples description: Agent for SAGE examples/tutorials/apps with runnable minimal changes. argument-hint: Include target folder (`examples`, `tutorials`, or `sage-apps`), goal, and run command. tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo', 'vscode.mermaid-chat-features/renderMermaidDiagram', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/suggest-fix', 'github.vscode-pull-request-github/searchSyntax', 'github.vscode-pull-request-github/doSearch', 'github.vscode-pull-request-github/renderIssues', 'github.vscode-pull-request-github/activePullRequest', 'github.vscode-pull-request-github/openPullRequest', 'ms-azuretools.vscode-containers/containerToolsConfig', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment', 'ms-toolsai.jupyter/configureNotebook', 'ms-toolsai.jupyter/listNotebookPackages', 'ms-toolsai.jupyter/installNotebookPackages', 'ms-vscode.cpp-devtools/Build_CMakeTools', 'ms-vscode.cpp-devtools/RunCtest_CMakeTools', 'ms-vscode.cpp-devtools/ListBuildTargets_CMakeTools', 'ms-vscode.cpp-devtools/ListTests_CMakeTools'] + +# SAGE Examples Agent + +## Scope + +- `examples/`, `tutorials/`, `sage-apps/` in `sage-examples` repo. + +## Rules + +- Flownet-first: no new `ray` imports/dependencies. +- Keep examples concise and runnable; avoid core-framework refactors here. +- Do not create new local virtual environments (`venv`/`.venv`); use the existing configured Python + environment. +- In conda environments, use `python -m pip`. +- Dependency changes must go to repo config files, not one-off install notes. + +## Workflow + +1. Read nearest README/QUICK_START first. +1. Implement minimal local change. +1. Run relevant example/test and update nearby docs if behavior changed. + +## Polyrepo coordination rules + +- Treat this repository as the only local source tree; do not assume sibling repositories exist. +- If a task spans multiple repositories, implement only this repo and explicitly list follow-up + repo/version-bump actions. +- Do not create `venv`/`.venv`; always use the existing configured Python environment. diff --git a/.github/agents/sage-examples.agent.md b/.github/agents/sage-examples.agent.md new file mode 100644 index 0000000..e8978f6 --- /dev/null +++ b/.github/agents/sage-examples.agent.md @@ -0,0 +1,45 @@ +```chatagent +______________________________________________________________________ + +## name: sage-examples description: Agent for SAGE examples, tutorials, and app entrypoints built on consolidated isage. argument-hint: Include target area (`examples`, `tutorials`, or `sage-apps`), goal, and any run/test command. tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo', 'vscode.mermaid-chat-features/renderMermaidDiagram', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/suggest-fix', 'github.vscode-pull-request-github/searchSyntax', 'github.vscode-pull-request-github/doSearch', 'github.vscode-pull-request-github/renderIssues', 'github.vscode-pull-request-github/activePullRequest', 'github.vscode-pull-request-github/openPullRequest', 'ms-azuretools.vscode-containers/containerToolsConfig', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment', 'ms-toolsai.jupyter/configureNotebook', 'ms-toolsai.jupyter/listNotebookPackages', 'ms-toolsai.jupyter/installNotebookPackages', 'ms-vscode.cpp-devtools/Build_CMakeTools', 'ms-vscode.cpp-devtools/RunCtest_CMakeTools', 'ms-vscode.cpp-devtools/ListBuildTargets_CMakeTools', 'ms-vscode.cpp-devtools/ListTests_CMakeTools'] + +# SAGE Examples Agent + +## Scope + +- `examples/`, `tutorials/`, and `sage-apps/` in the `sage-examples` repository. +- Teach and modify examples that depend on the consolidated `isage` core package. + +## Core Guidance + +- Prefer current `sage.*` surfaces such as `sage.foundation`, `sage.stream`, `sage.runtime`, `sage.serving`, and `sage.cli`. +- Keep examples stream-first. +- Treat distributed runtime as optional. +- Treat `isagellm` and other adapters such as `isage-rag` or `isage-neuromem` as external optional integrations. +- Do not reintroduce retired split-core package guidance. + +## Rules + +- Flownet-first: do not add new `ray` imports or dependencies. +- Keep examples concise, runnable, and local to this repository. +- Do not create new local virtual environments (`venv`/`.venv`); use the existing configured Python environment. +- In conda environments, use `python -m pip`. +- Dependency changes must be declared in repo config files, not as one-off install notes. +- Fail fast; do not add silent fallback logic that hides missing configuration or imports. + +## Workflow + +1. Read the nearest `README.md` or `QUICK_START.md` first. +2. Reuse existing example and tutorial patterns before inventing new structure. +3. Implement the smallest repo-local change that solves the task. +4. Run the relevant example, test, or check when practical. +5. Update nearby docs when behavior or usage changes. + +## Polyrepo Coordination Rules + +- Treat this repository as an independent examples repository. +- Do not assume sibling source repositories are available locally. +- If cross-repo rollout is needed, keep changes here minimal and note any follow-up version bump separately. +- Do not create `venv`/`.venv`; always reuse the existing configured Python environment. + +``` \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..49f0cca --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# SAGE Examples Copilot Instructions + +## Scope + +- Repository: `intellistream/sage-examples` (examples + app entry scripts, not SAGE core). +- Read docs first: `README.md`, `tutorials/README.md`, `tutorials/QUICK_START.md`, + `examples/README.md`. + +## Critical rules + +- Runtime direction: Flownet-first; do not add new `ray` imports/dependencies. +- Do not create new local virtual environments (`venv`/`.venv`); use the existing configured Python + environment. +- In conda environments, use `python -m pip` (never plain `pip`). +- Do not rely on manual install commands for permanent deps; declare in + `pyproject.toml`/`requirements.txt`. +- No silent fallback logic; fail fast with actionable errors. +- Do not re-add SAGE core internals here; depend on published `isage-*` packages. + +## Dependency and setup + +- If work depends on SAGE core behavior, ensure SAGE dev env is prepared via + `./quickstart.sh --dev --yes` in `SAGE/`. +- Keep dependency changes explicit and versioned in this repo files. + +## Workflow + +1. Confirm target area (`examples/`, `tutorials/`, or `sage-apps/`). +1. Implement minimal repo-local change. +1. Run relevant tests/linters and update nearby README when behavior changes. + +## High-signal paths + +- `examples/`, `tutorials/`, `sage-apps/`, `requirements.txt`, `README.md`. + +## Polyrepo coordination (mandatory) + +- This repository is an independent SAGE sub-repository and is developed/released independently. +- Do not assume sibling source directories exist locally in `intellistream/SAGE`. +- For cross-repo rollout, publish this repo/package first, then bump the version pin in + `SAGE/packages/sage/pyproject.toml` when applicable. +- Do not add local editable installs of other SAGE sub-packages in setup scripts or docs. + +## ๐Ÿšซ NEVER_CREATE_DOT_VENV_MANDATORY + +- ๆฐธ่ฟœไธ่ฆๅˆ›ๅปบ `.venv` ๆˆ– `venv`๏ผˆๆ— ไปปไฝ•ไพ‹ๅค–๏ผ‰ใ€‚ +- NEVER create `.venv`/`venv` in this repository under any circumstance. +- ๅฟ…้กปๅค็”จๅฝ“ๅ‰ๅทฒ้…็ฝฎ็š„้ž-venv Python ็Žฏๅขƒ๏ผˆๅฆ‚็Žฐๆœ‰ conda ็Žฏๅขƒ๏ผ‰ใ€‚ +- If any script/task suggests creating a virtualenv, skip that step and continue with the existing + environment. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..3cdd77b --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,115 @@ +name: Code Quality Checks + +on: + push: + branches: [main, main-dev] + pull_request: + branches: [main, main-dev] + workflow_dispatch: + +jobs: + pre-commit: + name: Pre-commit Hooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install pre-commit + run: | + pip install pre-commit + + - name: Run pre-commit + run: | + pre-commit run --all-files --show-diff-on-failure + + ruff: + name: Ruff Linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install ruff + run: | + pip install ruff==0.14.6 + + - name: Run ruff check + run: | + ruff check . + + - name: Run ruff format check + run: | + ruff format --check . + + mypy: + name: Type Checking (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -e ".[dev]" + + - name: Run mypy + run: | + mypy . --config-file=pyproject.toml --no-error-summary + continue-on-error: true # Type checking errors shouldn't block CI + + legacy-ray-guard: + name: Legacy Ray Term Guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Check for legacy Ray terms in source + # Fail fast if any new direct Ray imports or identifiers sneak in. + # All distributed-execution work must go via sageFlownet / SAGE facade APIs. + run: | + python - << 'EOF' + import re, sys + from pathlib import Path + FORBIDDEN = { + "import ray": re.compile(r"\bimport\s+ray\b"), + "from ray": re.compile(r"\bfrom\s+ray\b"), + "ray.init()": re.compile(r"\bray\.init\s*\("), + "ray.remote": re.compile(r"\bray\.remote\b"), + "RayQueueDescriptor": re.compile(r"\bRayQueueDescriptor\b"), + "RayServiceTask": re.compile(r"\bRayServiceTask\b"), + "use_ray=True": re.compile(r"\buse_ray\s*=\s*True\b"), + } + violations = [] + for path in Path(".").rglob("*.py"): + rel = path.relative_to(".") + if any(p.startswith(".") for p in rel.parts) or "__pycache__" in rel.parts: + continue + text = path.read_text(encoding="utf-8") + for term, pat in FORBIDDEN.items(): + for m in pat.finditer(text): + ln = text.count("\n", 0, m.start()) + 1 + violations.append(f"{rel}:{ln}: {term}") + if violations: + print("ERROR: Legacy Ray terms found (use sageFlownet / SAGE facade APIs instead):") + print("\n".join(sorted(violations))) + sys.exit(1) + print("OK: no legacy Ray terms found") + EOF diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ede28dd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,89 @@ +name: Tests + +on: + push: + branches: [main, main-dev] + pull_request: + branches: [main, main-dev] + workflow_dispatch: + +jobs: + test-examples: + name: Test Examples + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11'] + env: + # Skip tests requiring network/external services in CI + SAGE_EXAMPLES_MODE: test + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Install examples dependencies + run: | + pip install -e . + + - name: Run example tests + run: | + pytest examples/test_apps.py -v --tb=short + continue-on-error: true + + test-sage-apps-package: + name: Test sage-apps Package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install sage-apps package + run: | + pip install -e "./apps[dev]" + + - name: Run sage-apps tests + run: | + pytest apps/tests/ -v --tb=short --cov=apps/src/sage/apps --cov-report=term-missing + continue-on-error: true + + test-coverage: + name: Coverage Report + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + pytest tutorials/ apps/test_apps.py --cov=. --cov-report=xml --cov-report=term-missing + continue-on-error: true + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08c969c --- /dev/null +++ b/.gitignore @@ -0,0 +1,150 @@ +# ============================================================================ +# .gitignore for SAGE Examples +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Python +# ---------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# ---------------------------------------------------------------------------- +# Virtual environments +# ---------------------------------------------------------------------------- +venv/ +ENV/ +env/ +.venv/ +.conda/ + +# ---------------------------------------------------------------------------- +# IDEs and editors +# ---------------------------------------------------------------------------- +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +*.sublime-project +*.sublime-workspace + +# ---------------------------------------------------------------------------- +# Testing and coverage +# ---------------------------------------------------------------------------- +.pytest_cache/ +.benchmarks/ +.sage/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json +.ruff_cache/ + +# ---------------------------------------------------------------------------- +# Distribution and build +# ---------------------------------------------------------------------------- +dist/ +build/ +*.egg-info/ + +# ---------------------------------------------------------------------------- +# Documentation +# ---------------------------------------------------------------------------- +docs/_build/ +docs/.doctrees/ +site/ + +# ---------------------------------------------------------------------------- +# Logs and temporary files +# ---------------------------------------------------------------------------- +*.log +pytest.log +test.log +.sage/test/ +*.tmp +*.bak +*.swp + +# ---------------------------------------------------------------------------- +# Data files (ๅฏ่ƒฝๅŒ…ๅซๅคงๆ–‡ไปถๆˆ–ๆ•ๆ„Ÿๆ•ฐๆฎ) +# ---------------------------------------------------------------------------- +# Uncomment if needed: +# *.mp4 +# *.avi +# *.mov +# *.mkv +# *.dicom +# *.dcm + +# ---------------------------------------------------------------------------- +# Model files (้€šๅธธๅพˆๅคง๏ผŒไธๅบ”ๆไบคๅˆฐไป“ๅบ“) +# ---------------------------------------------------------------------------- +*.pth +*.pt +*.ckpt +*.h5 +*.pb +*.onnx +models/ +checkpoints/ + +# ---------------------------------------------------------------------------- +# Temporary improvement docs +# ---------------------------------------------------------------------------- +*_IMPROVEMENTS.md +IMPROVEMENTS.md + +# ---------------------------------------------------------------------------- +# Jupyter Notebooks +# ---------------------------------------------------------------------------- +.ipynb_checkpoints/ +*.ipynb_checkpoints + +# ---------------------------------------------------------------------------- +# MacOS +# ---------------------------------------------------------------------------- +.DS_Store +.AppleDouble +.LSOverride + +# ---------------------------------------------------------------------------- +# Linux +# ---------------------------------------------------------------------------- +*~ +.fuse_hidden* +.directory +.Trash-* + +# ---------------------------------------------------------------------------- +# Windows +# ---------------------------------------------------------------------------- +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +apps/src/sage/apps/medical_diagnosis/data/processed/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ba2536c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,71 @@ +default_language_version: + python: python3.11 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + args: [--unsafe] + - id: check-json + - id: check-toml + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-merge-conflict + - id: check-case-conflict + - id: mixed-line-ending + args: [--fix=lf] + - id: detect-private-key + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.6 + hooks: + - id: ruff + name: ruff check + args: [--fix, --exit-non-zero-on-fix, --config, tools/config/ruff.toml] + types_or: [python, pyi] + - id: ruff-format + name: ruff format + args: [--config, tools/config/ruff.toml] + types_or: [python, pyi] + +- repo: local + hooks: + - id: mypy + name: mypy + entry: mypy . --config-file=pyproject.toml --no-error-summary + language: system + pass_filenames: false + types_or: [python, pyi] + stages: [manual] + +- repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [--severity=warning] + +- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.15.0 + hooks: + - id: pretty-format-yaml + args: [--autofix, --indent, '2'] + +- repo: https://github.com/executablebooks/mdformat + rev: 0.7.21 + hooks: + - id: mdformat + args: [--wrap, '100'] + additional_dependencies: + - mdformat-gfm + - mdformat-black + +- repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: [--baseline, .secrets.baseline] + exclude: package.lock.json diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..2d2d39f --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,127 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": {}, + "generated_at": "2026-03-03T00:00:00Z" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..06fee29 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1298909 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# CHANGELOG + +All notable changes to this repository are documented in this file. + +## PyPI Verified Releases (`isage-examples`) + +Source: `https://pypi.org/pypi/isage-examples/json` (checked on 2026-02-14, UTC). + +- `0.1.1.0` โ€” 2026-02-14T05:23:57Z +- `0.1.0.3` โ€” 2026-02-14T05:03:24Z +- `0.1.0.2` โ€” 2026-02-13T03:45:26Z +- `0.1.0.1` โ€” 2026-02-12T15:11:12Z + +## [Unreleased] + +### Removed + +- Removed deprecated Copilot configuration/migration summary markdown documents in `docs/`. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9878158 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,38 @@ +# MANIFEST.in for isage-examples +# +# This file specifies additional files to include in the source distribution +# that are not automatically included by setuptools. + +# Include all documentation files +include README.md +include LICENSE +include requirements.txt + +# Include all configuration files +recursive-include tutorials *.yaml *.yml *.json *.txt *.md +recursive-include apps *.yaml *.yml *.json *.txt *.md + +# Include all data files +recursive-include tutorials/*/data * +recursive-include tutorials/*/config * +recursive-include apps/data * +recursive-include apps/config * +graft data + +# Exclude unwanted files +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__ +global-exclude .DS_Store +global-exclude *.so +global-exclude .git* +global-exclude *.egg-info + +# Exclude sage-apps (it has its own package) +prune sage-apps + +# Exclude tests and development files +prune tests +exclude .gitignore +exclude .pre-commit-config.yaml +exclude tox.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..f749166 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# SAGE Examples + +[![CI Status](https://github.com/intellistream/sage-examples/workflows/Tests/badge.svg)](https://github.com/intellistream/sage-examples/actions) +[![Code Quality](https://github.com/intellistream/sage-examples/workflows/Quality/badge.svg)](https://github.com/intellistream/sage-examples/actions) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Production-ready application examples for the SAGE framework. + +## ๐ŸŽฏ What is This? + +**sage-examples** showcases production application examples for +[SAGE](https://github.com/intellistream/SAGE): + +- **๐ŸŽฏ Examples**: Complete, runnable application demonstrations +- **๐Ÿ“ฆ apps Package**: Installable application library (published to PyPI as `iapps`) + +> **๏ฟฝ Looking for tutorials?** Visit +> [SAGE/tutorials](https://github.com/intellistream/SAGE/tree/main/tutorials) for learning +> materials. + +## ๐Ÿš€ Quick Start + +```bash +# Clone this repository +git clone https://github.com/intellistream/sage-examples.git +cd sage-examples + +# Install (all dependencies included) +pip install -e . + +# Run an application example +python examples/run_video_intelligence.py +``` + +> **New to SAGE?** Start with +> [SAGE/tutorials](https://github.com/intellistream/SAGE/tree/main/tutorials) first. + +## ๐Ÿ“ Repository Structure + +``` +sage-examples/ +โ”œโ”€โ”€ examples/ # ๐ŸŽฏ Production application examples +โ”‚ โ”œโ”€โ”€ run_video_intelligence.py +โ”‚ โ”œโ”€โ”€ run_medical_diagnosis.py +โ”‚ โ”œโ”€โ”€ run_smart_home.py +โ”‚ โ””โ”€โ”€ ... +โ”‚ +โ”œโ”€โ”€ apps/ # ๐Ÿ“ฆ Installable application package +โ”‚ โ”œโ”€โ”€ src/sage/apps/ # Application implementations +โ”‚ โ””โ”€โ”€ tests/ # Package tests +โ”‚ +โ”œโ”€โ”€ docs/ # ๐Ÿ“– Project documentation +โ””โ”€โ”€ pyproject.toml # Project configuration +``` + +## ๐Ÿ“š Learning vs Examples + +| Your Goal | Repository | +| --------------------------- | --------------------------------------------------------------------------- | +| **Learn SAGE basics** | [SAGE/tutorials](https://github.com/intellistream/SAGE/tree/main/tutorials) | +| **See production examples** | [sage-examples](https://github.com/intellistream/sage-examples) (this repo) | +| **Install applications** | `pip install iapps` | + +## ๐ŸŽฏ Application Examples + +Complete, runnable applications demonstrating real-world use cases: + +| Application | Description | Script | +| ------------------------- | -------------------------- | ------------------------------------ | +| ๐ŸŽฌ **Video Intelligence** | Multi-model video analysis | `examples/run_video_intelligence.py` | +| ๐Ÿฅ **Medical Diagnosis** | AI medical image analysis | `examples/run_medical_diagnosis.py` | +| ๐Ÿ  **Smart Home** | IoT automation system | `examples/run_smart_home.py` | +| ๐Ÿ“ฐ **Article Monitoring** | News monitoring pipeline | `examples/run_article_monitoring.py` | +| ๐Ÿ’ฌ **Auto-scaling Chat** | Dynamic scaling chat | `examples/run_auto_scaling_chat.py` | +| ๐Ÿ“š **Literature Recommendation** | Personalized scientific paper recommendation | `examples/run_literature_report_assistant.py` | + +See `examples/README.md` for details. + +## ๐Ÿ“ฆ Installation + +```bash +# Clone repository +git clone https://github.com/intellistream/sage-examples.git +cd sage-examples + +# Install (all dependencies included by default) +pip install -e . + +# Or install from PyPI +pip install isage-examples + +# Development mode (includes pytest, ruff, mypy) +pip install -e .[dev] +``` + +> **Note**: Following SAGE principles, all application dependencies are installed by default. No +> need for extra flags like `[video]` or `[medical]`. + +## ๐Ÿ—๏ธ SAGE Architecture Overview + +SAGE uses a strict 6-layer architecture with unidirectional dependencies: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ L6: Interface โ”‚ CLI, Web UI, Tools +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ L5: Applications โ”‚ Production Apps +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ L4: Middleware โ”‚ Domain Operators +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ L3: Core โ”‚ Execution + Algorithms +โ”‚ โ”œโ”€ Kernel (Batch/Stream Engine) โ”‚ +โ”‚ โ””โ”€ Libs (RAG/Agents/Algorithms) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ L2: Platform โ”‚ Scheduler, Storage +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ L1: Foundation โ”‚ Config, Logging, LLM +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Dependency Rule**: Upper layers can depend on lower layers (L6โ†’L5โ†’...โ†’L1), but never the reverse. + +## ๐Ÿ› ๏ธ Development + +### Setup + +```bash +# Install development dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install + +# Run all checks +pre-commit run --all-files +``` + +### Code Quality + +```bash +# Format code +ruff format . + +# Lint code +ruff check . + +# Auto-fix issues +ruff check --fix . + +# Type checking +mypy . +``` + +### Testing + +```bash +# Run all tests +pytest + +# Run specific tests +pytest examples/test_apps.py +pytest apps/tests/ + +# With coverage +pytest --cov=. --cov-report=html +``` + +See `docs/DEVELOPMENT.md` for complete development guide. + +## ๐Ÿ“– Documentation + +- **Examples Guide**: `examples/README.md` - Application examples +- **Development Guide**: `docs/DEVELOPMENT.md` - Contributing +- **SAGE Tutorials**: [SAGE/tutorials](https://github.com/intellistream/SAGE/tree/main/tutorials) - + Learn SAGE +- **SAGE Docs**: https://intellistream.github.io/SAGE + +## ๐Ÿค Contributing + +We welcome contributions! Please see: + +1. **Development Guide**: `docs/DEVELOPMENT.md` +1. **Code of Conduct**: Follow respectful collaboration +1. **Issue Tracker**: https://github.com/intellistream/sage-examples/issues + +### Adding Examples + +1. **Tutorials**: Add to [SAGE/tutorials](https://github.com/intellistream/SAGE/tree/main/tutorials) +1. **Applications**: + - Implementation โ†’ `apps/src/sage/apps/your_app/` + - Entry script โ†’ `examples/run_your_app.py` +1. **Tests**: Add tests and ensure they pass +1. **Dependencies**: Update `pyproject.toml` + +## ๐Ÿ”— Related Repositories + +- **SAGE Main**: https://github.com/intellistream/SAGE +- **SAGE Benchmark**: https://github.com/intellistream/sage-benchmark +- **PyPI Packages**: https://pypi.org/search/?q=isage + +## ๐Ÿ“„ License + +MIT License - see [LICENSE](LICENSE) for details. + +## ๐Ÿ™‹ Getting Help + +- **GitHub Issues**: Bug reports and feature requests +- **GitHub Discussions**: Questions and community support +- **GitHub Copilot**: Use "SAGE Examples Assistant" chat mode + +## ๐ŸŒŸ Star History + +If you find this project helpful, please consider giving it a โญ๏ธ! + +______________________________________________________________________ + +**Made with โค๏ธ by the IntelliStream Team** diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3537cd7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,12 @@ +""" +SAGE ๅบ”็”จ็คบไพ‹ๆจกๅ— + +ๅŒ…ๅซๅ„็ง็ฑปๅž‹็š„็คบไพ‹ๅบ”็”จ๏ผš +- tutorials: ๅŸบ็ก€ๆ•™็จ‹ๅ’Œๅ…ฅ้—จ็คบไพ‹ +- apps: ๅบ”็”จ็คบไพ‹ +- kernel: ๅ†…ๆ ธ็คบไพ‹ +- unlearning: ๆœบๅ™จ้—ๅฟ˜็คบไพ‹ +""" + +# ่ฟ™ไบ›ๆ˜ฏ็คบไพ‹็›ฎๅฝ•๏ผŒไธ้œ€่ฆๅœจ __all__ ไธญๅฃฐๆ˜Ž +# ็”จๆˆทๅฏไปฅ็›ดๆŽฅ่ฎฟ้—ฎ่ฟ™ไบ›็›ฎๅฝ•ไธญ็š„็คบไพ‹ๆ–‡ไปถ diff --git a/apps/MANIFEST.in b/apps/MANIFEST.in new file mode 100644 index 0000000..9cb3d71 --- /dev/null +++ b/apps/MANIFEST.in @@ -0,0 +1,26 @@ +# MANIFEST.in for isage-apps +# +# This file specifies additional files to include in the source distribution + +# Include all documentation files +include README.md +include LICENSE + +# Include all configuration and data files +recursive-include src/sage/apps *.yaml *.yml *.json *.txt *.md +recursive-include src/sage/apps/*/data * +recursive-include src/sage/apps/*/config * + +# Include type hints +include src/sage/apps/py.typed + +# Exclude unwanted files +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__ +global-exclude .DS_Store +global-exclude *.so +global-exclude .git* + +# Exclude tests +prune tests diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..4be3e0c --- /dev/null +++ b/apps/README.md @@ -0,0 +1,215 @@ +# SAGE Applications + +Real-world AI applications built on the SAGE framework, showcasing end-to-end solutions for various +domains. + +## Overview + +`isage-apps` provides production-ready applications demonstrating SAGE's capabilities: + +- **Video Intelligence**: Multi-model video analysis pipeline with CLIP and MobileNetV3 +- **Medical Diagnosis**: AI-assisted medical image analysis for healthcare + +## Installation + +### Basic Installation + +```bash +pip install isage-apps +``` + +### Install with Specific Applications + +```bash +# Video intelligence only +pip install isage-apps[video] + +# Medical diagnosis only +pip install isage-apps[medical] + +# All applications +pip install isage-apps[all] +``` + +### Development Installation + +```bash +cd packages/sage-apps +pip install -e ".[dev]" +``` + +## ๐Ÿ“– Quick Start + +```bash +# Run video intelligence demo +pip install isage-apps[video] +python -m sage.apps.video.video_intelligence_pipeline --video path/to/video.mp4 + +# Run medical diagnosis demo +pip install isage-apps[medical] +python -m sage.apps.medical_diagnosis.run_diagnosis +``` + +## Applications + +### 1. Video Intelligence + +Advanced video analysis pipeline combining multiple AI models: + +- **Frame sampling and preprocessing** +- **Zero-shot scene understanding** (CLIP) +- **Object classification** (MobileNetV3) +- **Temporal anomaly detection** +- **Sliding-window summarization** +- **Keyed event aggregation** + +**Quick Start:** + +```bash +pip install isage-apps[video] +python -m sage.apps.video.video_intelligence_pipeline --video path/to/video.mp4 +``` + +**Features:** + +- Multi-model inference pipeline +- Real-time processing with SAGE operators +- Structured JSON output (timeline, summary, events) +- Console progress monitoring +- Graceful degradation (works offline with cached models) + +**Documentation:** See `sage/apps/video/README_intelligence_demo.md` + +### 2. Medical Diagnosis + +AI-assisted diagnostic system for medical imaging: + +- **Multi-agent architecture** (diagnostic, image analysis, report generation) +- **Knowledge-based reasoning** +- **Structured medical reports** +- **Training and evaluation tools** + +**Quick Start:** + +```bash +pip install isage-apps[medical] +python -m sage.apps.medical_diagnosis.run_diagnosis +``` + +**Features:** + +- Agent-based diagnostic workflow +- Medical knowledge base integration +- Configurable diagnostic criteria +- Report generation + +**Documentation:** See `sage/apps/medical_diagnosis/README.md` + +## Package Structure + +```text +sage-apps/ +โ”œโ”€โ”€ src/sage/apps/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ video/ # Video intelligence application +โ”‚ โ”‚ โ”œโ”€โ”€ video_intelligence_pipeline.py +โ”‚ โ”‚ โ”œโ”€โ”€ operators/ # SAGE operators for video +โ”‚ โ”‚ โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”‚ โ””โ”€โ”€ README_intelligence_demo.md +โ”‚ โ””โ”€โ”€ medical_diagnosis/ # Medical diagnosis application +โ”‚ โ”œโ”€โ”€ run_diagnosis.py +โ”‚ โ”œโ”€โ”€ agents/ # Diagnostic agents +โ”‚ โ”œโ”€โ”€ config/ # Agent configurations +โ”‚ โ”œโ”€โ”€ data/ # Medical datasets +โ”‚ โ””โ”€โ”€ README.md +โ””โ”€โ”€ tests/ # Application tests +``` + +## Dependencies + +### Core Framework + +- `isage>=0.3.0` - Consolidated SAGE core (`sage.foundation`, `sage.stream`, `sage.runtime`) +- Optional adapters such as `isagellm` / `isage-rag` when a specific app needs external inference or retrieval backends + +### Application-Specific + +**Video Intelligence:** + +- `opencv-python` - Video processing +- `torch` - Deep learning +- `transformers` - CLIP and language models + +**Medical Diagnosis:** + +- `pillow` - Image processing +- `scikit-learn` - ML utilities + +## Usage Examples + +### Video Intelligence + +```python +from sage.apps.video.video_intelligence_pipeline import main + +# Run with custom video +main(["--video", "my_video.mp4", "--max-frames", "100"]) +``` + +### Medical Diagnosis + +```python +from sage.apps.medical_diagnosis.run_diagnosis import run_diagnosis + +# Run diagnostic pipeline +run_diagnosis(config_path="config/agent_config.yaml") +``` + +## Development + +### Running Tests + +```bash +pytest tests/ +``` + +### Code Quality + +```bash +# Format code +black src/ + +# Lint +ruff check src/ + +# Type checking +mypy src/ +``` + +## CI/CD Notes + +**Video Intelligence:** + +- Requires HuggingFace model downloads (~170MB) +- Tagged with `@test:skip` in CI due to network restrictions +- Test locally with: `python -m sage.apps.video.video_intelligence_pipeline` + +**Medical Diagnosis:** + +- Works in CI (uses local data) +- Test with: `pytest tests/test_medical_diagnosis.py` + +## Contributing + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. + +## License + +MIT License - see [LICENSE](../../LICENSE) for details. + +## Related Documentation + +- [SAGE Framework](../../README.md) +- [Video Intelligence Demo](src/sage/apps/video/README_intelligence_demo.md) +- [Medical Diagnosis](src/sage/apps/medical_diagnosis/README.md) +- [CI Test Fix](src/sage/apps/video/CI_TEST_FIX.md) diff --git a/apps/pyproject.toml b/apps/pyproject.toml new file mode 100644 index 0000000..db443d0 --- /dev/null +++ b/apps/pyproject.toml @@ -0,0 +1,191 @@ +[build-system] +requires = [ + "setuptools>=64", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "isage-apps" +dynamic = [ + "version", +] +description = "SAGE Applications - Real-world AI applications built on SAGE framework" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "IntelliStream Team", email = "shuhao_zhang@hust.edu.cn" }, +] +keywords = [ + "sage", + "applications", + "ai", + "video-intelligence", + "medical-diagnosis", + "computer-vision", + "healthcare", + "article-monitoring", + "auto-scaling", + "smart-home", + "work-report", + "intellistream", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Intended Audience :: Healthcare Industry", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Image Recognition", + "Topic :: Scientific/Engineering :: Medical Science Apps.", + "Topic :: Multimedia :: Video", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + # SAGE ๆ ธๅฟƒไพ่ต–๏ผˆๅ•ไธ€ไธปไป“๏ผ‰ + "isage>=0.3.0", + + # ๅŸบ็ก€ๅทฅๅ…ท + "pyyaml>=6.0", + "pillow>=10.0.0", + "numpy>=1.26.0,<2.3.0", + + # ๆ‰€ๆœ‰ๅบ”็”จไพ่ต–๏ผˆ็ปŸไธ€ๅฎ‰่ฃ…๏ผŒๆ— ้œ€้ขๅค–ๅฏ้€‰็ป„๏ผ‰ + "opencv-python-headless>=4.5.0", # ่ง†้ข‘ๅค„็†๏ผˆๆ— GUIไพ่ต–๏ผ‰ + "torch>=2.7.0,<3.0.0", # PyTorch + "torchvision>=0.22.0,<1.0.0", # ่ง†่ง‰ๆจกๅž‹ + "transformers>=4.52.0", # CLIP ๅ’Œ transformers + "scikit-learn>=1.3.0", # ๆœบๅ™จๅญฆไน ๅทฅๅ…ท + "pydicom>=2.3.0", # DICOM ๅŒปๅญฆๅ›พๅƒๆ ผๅผ + "beautifulsoup4>=4.9.0", # HTML ่งฃๆž + "requests>=2.25.0", # HTTP ่ฏทๆฑ‚ + "redis>=5.0.0,<6.0.0", # Redis ็ผ“ๅญ˜ + "paho-mqtt>=1.6.0", # MQTT ๅ่ฎฎ + "jinja2>=3.0.0", # ๆจกๆฟๅผ•ๆ“Ž + "markdown>=3.3.0", # Markdown ๅค„็† +] + +license = "MIT" + +# ---------------------------------------------------------------------------- +# ๅฏ้€‰ไพ่ต–็ป„ - ไป…ๅผ€ๅ‘ๆจกๅผ๏ผˆ้ตๅพช SAGE ๅŽŸๅˆ™๏ผšๅชๆœ‰ dev๏ผ‰ +# ---------------------------------------------------------------------------- +[project.optional-dependencies] + +# ๅผ€ๅ‘ๅ’Œๆต‹่ฏ•ไพ่ต– +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "ruff==0.14.6", # ๅ›บๅฎš็‰ˆๆœฌไธŽ SAGE ไธปไป“ๅบ“ไธ€่‡ด + "mypy>=1.0.0", +] + +# ---------------------------------------------------------------------------- +# ้กน็›ฎ้“พๆŽฅ +# ---------------------------------------------------------------------------- +[project.urls] +Homepage = "https://github.com/intellistream/SAGE" +Documentation = "https://intellistream.github.io/SAGE" +Repository = "https://github.com/intellistream/sage-examples" +Issues = "https://github.com/intellistream/SAGE/issues" +"Source Code" = "https://github.com/intellistream/sage-examples/tree/main/sage-apps" + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["sage*"] +namespaces = true + +[tool.setuptools.dynamic.version] +attr = "sage.apps._version.__version__" + +[tool.setuptools.package-data] +"sage.apps" = [ + "py.typed", +] +"sage.apps.video" = [ + "config/*.yaml", + "data/**/*", +] +"sage.apps.medical_diagnosis" = [ + "config/*.yaml", + "data/**/*", +] + +[tool.black] +line-length = 100 +target-version = [ + "py310", + "py311", +] +include = "\\.pyi?$" + +[tool.mypy] +python_version = "3.10" +cache_dir = "../../.sage/cache/mypy" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +ignore_missing_imports = true + +[tool.ruff] +extend = "../tools/config/ruff.toml" + +[tool.pytest.ini_options] +testpaths = [ + "tests", + "src", +] +pythonpath = ["src"] +python_files = [ + "test_*.py", + "*_test.py", +] +python_classes = [ + "Test*", +] +python_functions = [ + "test_*", +] +addopts = [ + "--benchmark-storage=../../.sage/benchmarks", + "-o", + "cache_dir=../../.sage/cache/pytest", + "--strict-markers", + "--strict-config", + "--verbose", + "-ra", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", + "video: marks tests as video intelligence tests", + "medical: marks tests as medical diagnosis tests", +] + +[tool.coverage.run] +source = [ + "src/sage", +] +omit = [ + "*/tests/*", + "*/test_*.py", + "*/_test_*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] diff --git a/apps/src/sage/apps/__init__.py b/apps/src/sage/apps/__init__.py new file mode 100644 index 0000000..41caa26 --- /dev/null +++ b/apps/src/sage/apps/__init__.py @@ -0,0 +1,20 @@ +"""SAGE applications built on the consolidated main-repo runtime. + +This package contains production-ready applications demonstrating SAGE's +capabilities across various domains: + +- video: Video intelligence and analysis +- medical_diagnosis: AI-assisted medical imaging diagnosis +- work_report_generator: Weekly/daily work report generator with GitHub integration +- literature_report_assistant: Literature search and reading report generation assistant + +Architecture: +- ็›ดๆŽฅไพ่ต–ไธปไป“ `sage` ๆไพ›็š„ๆตใ€่ฟ่กŒๆ—ถไธŽๆœๅŠก่ƒฝๅŠ› +- ไฟๆŒๅบ”็”จๅฑ‚ๆœ€ๅฐๅฐ่ฃ…๏ผŒๆไพ›็ซฏๅˆฐ็ซฏ็คบไพ‹ +""" + +from ._version import __version__ + +__all__ = [ + "__version__", +] diff --git a/apps/src/sage/apps/_version.py b/apps/src/sage/apps/_version.py new file mode 100644 index 0000000..71e457c --- /dev/null +++ b/apps/src/sage/apps/_version.py @@ -0,0 +1,6 @@ +"""Version information for sage-apps package.""" + +# ็‹ฌ็ซ‹็กฌ็ผ–็ ็‰ˆๆœฌ +__version__ = "0.2.5" +__author__ = "IntelliStream Team" +__email__ = "shuhao_zhang@hust.edu.cn" diff --git a/apps/src/sage/apps/article_monitoring/README.md b/apps/src/sage/apps/article_monitoring/README.md new file mode 100644 index 0000000..423756f --- /dev/null +++ b/apps/src/sage/apps/article_monitoring/README.md @@ -0,0 +1,176 @@ +# Article Monitoring System + +An intelligent article monitoring system built on SAGE framework that continuously fetches papers +from arXiv, performs multi-level filtering, and provides personalized recommendations. + +## Features + +- **Real-time Data Stream Processing**: Built on SAGE operators for efficient stream processing +- **Multi-level Filtering**: + - Keyword-based filtering using bag-of-words matching + - Semantic filtering using text similarity +- **Personalized Recommendations**: Ranks articles based on user interests +- **SAGE Integration**: Uses BatchFunction, MapFunction, and SinkFunction +- **Configurable**: Easy to customize keywords and topics + +## Quick Start + +```python +from sage.apps.article_monitoring import run_article_monitoring_pipeline + +# Run with default settings +run_article_monitoring_pipeline() + +# Or customize parameters +run_article_monitoring_pipeline( + keywords=["machine learning", "deep learning", "neural network"], + interest_topics=[ + "artificial intelligence and machine learning applications", + "natural language processing and text analysis", + ], + category="cs.AI", + max_articles=20, +) +``` + +## Command Line Usage + +```bash +# Use default settings +python -m sage.apps.article_monitoring.pipeline + +# Custom keywords and topics +python -m sage.apps.article_monitoring.pipeline \ + --keywords "transformer,attention,bert" \ + --topics "natural language processing and transformers" \ + --category cs.CL \ + --max-articles 30 + +# Verbose mode +python -m sage.apps.article_monitoring.pipeline --verbose +``` + +## Use Cases + +- Academic researchers tracking papers in their field +- Research teams monitoring specific topics +- Students discovering relevant publications +- Literature review automation + +## Configuration + +### Categories + +Common arXiv categories: + +- `cs.AI` - Artificial Intelligence +- `cs.LG` - Machine Learning +- `cs.CL` - Computation and Language +- `cs.CV` - Computer Vision +- `stat.ML` - Machine Learning (Statistics) + +### Filtering Parameters + +- `keywords`: List of keywords for initial filtering +- `interest_topics`: List of topics for semantic analysis +- `max_articles`: Maximum number of articles to fetch per run + +## Architecture + +The pipeline consists of three main stages: + +1. **Article Fetching**: Retrieves recent papers from arXiv API +1. **Keyword Filtering**: Filters based on exact keyword matches +1. **Semantic Filtering**: Further filters using semantic similarity + +``` +arXiv API โ†’ Fetch Articles โ†’ Keyword Filter โ†’ Semantic Filter โ†’ Ranked Results +``` + +## Example Output + +``` +====================================================================== +SAGE Article Monitoring System +====================================================================== +Category: cs.AI +Keywords: ['machine learning', 'deep learning'] +Interest Topics: ['artificial intelligence applications'] +====================================================================== + +## Pipeline Architecture + +The system uses SAGE stream processing operators: + +``` + +ArxivSource (BatchFunction) โ†“ KeywordFilter (MapFunction) โ†“ SemanticFilter (MapFunction) โ†“ +ArticleScorer (MapFunction) โ†“ ArticleRankingSink (SinkFunction) + +``` + +### Operators + +- **ArxivSource**: Fetches articles from arXiv API +- **KeywordFilter**: Filters based on keyword matching +- **SemanticFilter**: Filters based on semantic similarity (Jaccard) +- **ArticleScorer**: Calculates final ranking scores +- **ArticleRankingSink**: Collects and displays ranked results + +## Example Output + +``` + +# ====================================================================== ๐Ÿ” SAGE Article Monitoring System + +# Category: cs.AI Max Articles: 10 Keywords: ['machine learning', 'deep learning', 'neural network'] Interest Topics: artificial intelligence and machine... + +๐Ÿ“ก Starting pipeline... + +# ====================================================================== ๐Ÿ“š Recommended Articles (8 found) + +1. Deep Learning for Time Series Forecasting in Stream Processing Authors: John Doe, Jane Smith + Score: 3.45 (keyword: 3.0, semantic: 0.45) URL: http://arxiv.org/abs/2401.00001 Abstract: We + propose a novel deep learning approach for time series... + +1. Machine Learning Pipeline Optimization Authors: David Lee Score: 2.30 (keyword: 2.0, semantic: + 0.30) URL: http://arxiv.org/abs/2401.00004 Abstract: Optimizing machine learning pipelines for + better performance... + +# ====================================================================== โœ… Pipeline completed in 2.34s Recommended 8 articles + +``` +``` + +## Testing + +Run the example script: + +```bash +python examples/apps/run_article_monitoring.py +``` + +Or with custom parameters: + +```bash +python examples/apps/run_article_monitoring.py \ + --keywords "transformer,attention,bert" \ + --topics "natural language processing" \ + --category cs.CL \ + --max-articles 30 +``` + +## Dependencies + +- Python 3.8+ +- Standard library only (no external dependencies for basic functionality) +- Optional: SAGE framework for advanced stream processing + +## Future Enhancements + +- Integration with embedding models for true semantic search +- User feedback learning +- Multi-source support (beyond arXiv) +- Email/Slack notifications +- Web dashboard +- Persistent storage with SageDB diff --git a/apps/src/sage/apps/article_monitoring/__init__.py b/apps/src/sage/apps/article_monitoring/__init__.py new file mode 100644 index 0000000..4efca59 --- /dev/null +++ b/apps/src/sage/apps/article_monitoring/__init__.py @@ -0,0 +1,20 @@ +""" +Article Monitoring System + +An intelligent article monitoring system that continuously fetches papers from arXiv, +performs multi-level filtering (keyword-based and semantic), and provides personalized +recommendations to users. + +Built on SAGE framework using stream processing operators. + +Features: +- Real-time article fetching from arXiv +- Keyword-based filtering using bag-of-words +- Semantic filtering using text similarity +- Personalized recommendations +- Stream processing with SAGE operators +""" + +from .pipeline import run_article_monitoring_pipeline + +__all__ = ["run_article_monitoring_pipeline"] diff --git a/apps/src/sage/apps/article_monitoring/operators.py b/apps/src/sage/apps/article_monitoring/operators.py new file mode 100644 index 0000000..3924523 --- /dev/null +++ b/apps/src/sage/apps/article_monitoring/operators.py @@ -0,0 +1,328 @@ +""" +SAGE Operators for Article Monitoring System + +Implements source, map, filter, and sink operators for the article monitoring pipeline. +""" + +from __future__ import annotations + +import re +import time +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Any + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + + +@dataclass +class Article: + """Article metadata from arXiv.""" + + id: str + title: str + authors: list[str] + abstract: str + published: str + categories: list[str] + url: str + keyword_score: float = 0.0 + semantic_score: float = 0.0 + total_score: float = 0.0 + + +class ArxivSource(BatchFunction): + """ + BatchFunction source that fetches articles from arXiv API. + + Emits one article at a time as a data packet. + """ + + def __init__( + self, + category: str = "cs.AI", + max_results: int = 10, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.category = category + self.max_results = max_results + self.articles: list[dict[str, Any]] = [] + self.index = 0 + self.fetched = False + + def _fetch_articles(self) -> list[dict[str, Any]]: + """Fetch articles from arXiv API.""" + query = f"cat:{self.category}" + params = { + "search_query": query, + "start": 0, + "max_results": self.max_results, + "sortBy": "submittedDate", + "sortOrder": "descending", + } + + url = f"http://export.arxiv.org/api/query?{urllib.parse.urlencode(params)}" + + try: + with urllib.request.urlopen(url, timeout=10) as response: + data = response.read().decode("utf-8") + return self._parse_arxiv_response(data) + except Exception as e: + self.logger.warning(f"Error fetching from arXiv: {e}, using mock data") + return self._get_mock_articles() + + def _parse_arxiv_response(self, xml_data: str) -> list[dict[str, Any]]: + """Parse arXiv API XML response.""" + articles = [] + root = ET.fromstring(xml_data) + ns = {"atom": "http://www.w3.org/2005/Atom"} + + for entry in root.findall("atom:entry", ns): + try: + id_elem = entry.find("atom:id", ns) + title_elem = entry.find("atom:title", ns) + abstract_elem = entry.find("atom:summary", ns) + published_elem = entry.find("atom:published", ns) + + if not all([id_elem, title_elem, abstract_elem, published_elem]): + continue + + article_id = id_elem.text.split("/")[-1] # type: ignore + title = title_elem.text.strip() # type: ignore + abstract = abstract_elem.text.strip() # type: ignore + published = published_elem.text # type: ignore + url = id_elem.text # type: ignore + + authors = [ + author.find("atom:name", ns).text # type: ignore + for author in entry.findall("atom:author", ns) + if author.find("atom:name", ns) is not None + ] + + categories = [cat.attrib["term"] for cat in entry.findall("atom:category", ns)] + + articles.append( + { + "id": article_id, + "title": title, + "authors": authors, + "abstract": abstract, + "published": published, + "categories": categories, + "url": url, + } + ) + except Exception as e: + self.logger.warning(f"Error parsing entry: {e}") + continue + + return articles + + def _get_mock_articles(self) -> list[dict[str, Any]]: + """Generate mock articles for testing/offline mode.""" + return [ + { + "id": "2401.00001", + "title": "Deep Learning for Time Series Forecasting in Stream Processing", + "authors": ["John Doe", "Jane Smith"], + "abstract": "We propose a novel deep learning approach for time series forecasting in distributed stream processing systems...", + "published": "2024-01-01T00:00:00Z", + "categories": ["cs.LG", "cs.AI"], + "url": "http://arxiv.org/abs/2401.00001", + }, + { + "id": "2401.00002", + "title": "Semantic Analysis in Natural Language Processing", + "authors": ["Alice Johnson"], + "abstract": "This paper explores advanced semantic analysis techniques for NLP tasks using transformer models...", + "published": "2024-01-01T01:00:00Z", + "categories": ["cs.CL", "cs.AI"], + "url": "http://arxiv.org/abs/2401.00002", + }, + { + "id": "2401.00003", + "title": "Distributed Systems for Real-time Stream Processing", + "authors": ["Bob Chen", "Carol White"], + "abstract": "We present a distributed architecture for processing data streams in real-time with low latency...", + "published": "2024-01-01T02:00:00Z", + "categories": ["cs.DC", "cs.DB"], + "url": "http://arxiv.org/abs/2401.00003", + }, + { + "id": "2401.00004", + "title": "Machine Learning Pipeline Optimization", + "authors": ["David Lee"], + "abstract": "Optimizing machine learning pipelines for better performance and resource utilization in production systems...", + "published": "2024-01-01T03:00:00Z", + "categories": ["cs.LG", "cs.AI"], + "url": "http://arxiv.org/abs/2401.00004", + }, + { + "id": "2401.00005", + "title": "Graph Neural Networks for Social Network Analysis", + "authors": ["Emma Wilson", "Frank Zhang"], + "abstract": "Applying graph neural networks to analyze complex social network structures and dynamics...", + "published": "2024-01-01T04:00:00Z", + "categories": ["cs.SI", "cs.LG"], + "url": "http://arxiv.org/abs/2401.00005", + }, + ] + + def execute(self) -> dict[str, Any] | None: + """Execute batch function to emit one article at a time.""" + # Fetch articles on first execution + if not self.fetched: + self.logger.info(f"Fetching articles from arXiv (category: {self.category})...") + self.articles = self._fetch_articles() + self.fetched = True + self.logger.info(f"Fetched {len(self.articles)} articles") + + # Emit one article per call + if self.index < len(self.articles): + article = self.articles[self.index] + self.index += 1 + return article + + # All articles emitted + return None + + +class KeywordFilter(MapFunction): + """ + MapFunction that filters articles based on keyword matching. + + Returns the article with keyword_score if it passes, otherwise returns None. + """ + + def __init__(self, keywords: list[str], min_score: float = 1.0, **kwargs) -> None: + super().__init__(**kwargs) + self.keywords = [kw.lower() for kw in keywords] + self.min_score = min_score + + def execute(self, data: dict[str, Any]) -> dict[str, Any] | None: + """Filter article based on keyword matching.""" + text = (data.get("title", "") + " " + data.get("abstract", "")).lower() + score = sum(1 for kw in self.keywords if kw in text) + + if score >= self.min_score: + data["keyword_score"] = float(score) + return data + return None + + +class SemanticFilter(MapFunction): + """ + MapFunction that filters articles based on semantic similarity. + + Uses simplified word overlap as a proxy for semantic similarity. + """ + + def __init__(self, interest_topics: list[str], min_similarity: float = 0.05, **kwargs) -> None: + super().__init__(**kwargs) + self.interest_topics = [topic.lower() for topic in interest_topics] + self.min_similarity = min_similarity + + def execute(self, data: dict[str, Any]) -> dict[str, Any] | None: + """Filter article based on semantic similarity.""" + text = (data.get("title", "") + " " + data.get("abstract", "")).lower() + text_words = set(re.findall(r"\w+", text)) + + max_similarity = 0.0 + for topic in self.interest_topics: + topic_words = set(re.findall(r"\w+", topic)) + if not topic_words: + continue + + # Simple Jaccard similarity + intersection = len(text_words.intersection(topic_words)) + union = len(text_words.union(topic_words)) + similarity = intersection / union if union > 0 else 0.0 + max_similarity = max(max_similarity, similarity) + + if max_similarity >= self.min_similarity: + data["semantic_score"] = float(max_similarity * 10) # Scale for display + data["total_score"] = data.get("keyword_score", 0) + data["semantic_score"] + return data + return None + + +class ArticleScorer(MapFunction): + """ + MapFunction that calculates and adds final score to articles. + """ + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Calculate total score for ranking.""" + keyword_score = data.get("keyword_score", 0.0) + semantic_score = data.get("semantic_score", 0.0) + data["total_score"] = keyword_score + semantic_score + return data + + +class ArticleRankingSink(SinkFunction): + """ + SinkFunction that collects and displays ranked articles. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.articles: list[dict[str, Any]] = [] + self.start_time = time.time() + + def execute(self, data: dict[str, Any]) -> None: + """Collect article for final ranking.""" + self.articles.append(data) + + def close(self) -> None: + """Display ranked results when pipeline completes.""" + elapsed = time.time() - self.start_time + + # Sort by total score + self.articles.sort(key=lambda x: x.get("total_score", 0), reverse=True) + + print("\n" + "=" * 70) + print(f"๐Ÿ“š Recommended Articles ({len(self.articles)} found)") + print("=" * 70) + + for i, article in enumerate(self.articles, 1): + print(f"\n{i}. {article.get('title', 'Unknown')}") + authors = article.get("authors", []) + if authors: + print(f" Authors: {', '.join(authors[:3])}") + if len(authors) > 3: + print(f" (+{len(authors) - 3} more)") + print( + f" Score: {article.get('total_score', 0):.2f} " + f"(keyword: {article.get('keyword_score', 0):.1f}, " + f"semantic: {article.get('semantic_score', 0):.2f})" + ) + print(f" URL: {article.get('url', 'N/A')}") + abstract = article.get("abstract", "") + if abstract: + preview = abstract[:150] + "..." if len(abstract) > 150 else abstract + print(f" Abstract: {preview}") + + print("\n" + "=" * 70) + print(f"โœ… Pipeline completed in {elapsed:.2f}s") + print(f" Recommended {len(self.articles)} articles") + print("=" * 70 + "\n") + + +class ArticleLogSink(SinkFunction): + """ + SinkFunction that logs each processed article. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.count = 0 + + def execute(self, data: dict[str, Any]) -> None: + """Log article processing.""" + self.count += 1 + title = data.get("title", "Unknown") + score = data.get("total_score", 0) + self.logger.info(f"[{self.count}] Processed: {title[:50]}... (score: {score:.2f})") diff --git a/apps/src/sage/apps/article_monitoring/pipeline.py b/apps/src/sage/apps/article_monitoring/pipeline.py new file mode 100644 index 0000000..fc8524b --- /dev/null +++ b/apps/src/sage/apps/article_monitoring/pipeline.py @@ -0,0 +1,189 @@ +""" +Article Monitoring Pipeline + +Main pipeline implementation using SAGE operators for article monitoring. +""" + +from __future__ import annotations + +from sage.foundation import CustomLogger +from sage.runtime import LocalEnvironment + +from .operators import ( + ArticleLogSink, + ArticleRankingSink, + ArticleScorer, + ArxivSource, + KeywordFilter, + SemanticFilter, +) + + +def run_article_monitoring_pipeline( + keywords: list[str] | None = None, + interest_topics: list[str] | None = None, + category: str = "cs.AI", + max_articles: int = 10, + verbose: bool = False, +) -> None: + """ + Run the article monitoring pipeline using SAGE framework. + + Args: + keywords: List of keywords for initial filtering + interest_topics: List of topics for semantic filtering + category: arXiv category to monitor + max_articles: Maximum number of articles to fetch + verbose: Enable verbose logging + + Example: + >>> run_article_monitoring_pipeline( + ... keywords=["machine learning", "deep learning"], + ... interest_topics=["artificial intelligence applications"], + ... category="cs.AI", + ... max_articles=20 + ... ) + """ + # Default parameters + if keywords is None: + keywords = [ + "machine learning", + "deep learning", + "neural network", + "stream processing", + ] + + if interest_topics is None: + interest_topics = [ + "artificial intelligence and machine learning applications", + "data stream processing and real-time systems", + "distributed computing and scalability", + ] + + print("=" * 70) + print("๐Ÿ” SAGE Article Monitoring System") + print("=" * 70) + print(f"Category: {category}") + print(f"Max Articles: {max_articles}") + print(f"Keywords: {keywords}") + print( + f"Interest Topics: {', '.join(t[:40] + '...' if len(t) > 40 else t for t in interest_topics)}" + ) + print("=" * 70) + print() + + # Create SAGE environment + env = LocalEnvironment("article_monitoring") + + # Build pipeline: + # 1. ArxivSource: Fetch articles from arXiv + # 2. KeywordFilter: Filter by keywords + # 3. SemanticFilter: Filter by semantic similarity + # 4. ArticleScorer: Calculate final scores + # 5. ArticleRankingSink: Collect and display results + pipeline = ( + env.from_batch( + ArxivSource, + category=category, + max_results=max_articles, + ) + .map(KeywordFilter, keywords=keywords, min_score=1.0) + .map(SemanticFilter, interest_topics=interest_topics, min_similarity=0.05) + .map(ArticleScorer) + .sink(ArticleRankingSink) + ) + + # Optionally add logging sink + if verbose: + pipeline.sink(ArticleLogSink) + + # Execute pipeline + print("๐Ÿ“ก Starting pipeline...") + print() + env.submit(autostop=True) + + +def main(): + """Main entry point for command-line usage.""" + import argparse + + parser = argparse.ArgumentParser( + description="SAGE Article Monitoring System - Monitor and filter arXiv papers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Use default settings + python -m sage.apps.article_monitoring.pipeline + + # Custom keywords and topics + python -m sage.apps.article_monitoring.pipeline \\ + --keywords "transformer,attention,bert" \\ + --topics "natural language processing and transformers" \\ + --category cs.CL \\ + --max-articles 30 + + # Verbose mode + python -m sage.apps.article_monitoring.pipeline --verbose + """, + ) + + parser.add_argument( + "--keywords", + "-k", + type=str, + help="Comma-separated list of keywords (default: machine learning,deep learning,neural network)", + ) + + parser.add_argument( + "--topics", + "-t", + type=str, + help="Comma-separated list of interest topics", + ) + + parser.add_argument( + "--category", + "-c", + type=str, + default="cs.AI", + help="arXiv category to monitor (default: cs.AI)", + ) + + parser.add_argument( + "--max-articles", + "-m", + type=int, + default=10, + help="Maximum number of articles to fetch (default: 10)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Parse keywords + keywords = None + if args.keywords: + keywords = [kw.strip() for kw in args.keywords.split(",")] + + # Parse topics + topics = None + if args.topics: + topics = [t.strip() for t in args.topics.split(",")] + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + # Run pipeline + run_article_monitoring_pipeline( + keywords=keywords, + interest_topics=topics, + category=args.category, + max_articles=args.max_articles, + verbose=args.verbose, + ) + + +if __name__ == "__main__": + main() diff --git a/apps/src/sage/apps/auto_scaling_chat/README.md b/apps/src/sage/apps/auto_scaling_chat/README.md new file mode 100644 index 0000000..0f439ef --- /dev/null +++ b/apps/src/sage/apps/auto_scaling_chat/README.md @@ -0,0 +1,193 @@ +# Auto-Scaling Chat System + +An intelligent auto-scaling chat system built on SAGE framework demonstrating elastic resource +management and load balancing capabilities. + +## Features + +- **Simulated User Traffic**: Variable load patterns (ramp up, peak, ramp down) +- **Automatic Scaling**: Dynamic server scaling based on load thresholds +- **Load Balancing**: Round-robin distribution across available servers +- **Resource Monitoring**: Real-time metrics and performance tracking +- **SAGE Integration**: Built with BatchFunction, MapFunction, SinkFunction +- **Configurable Policies**: Customizable scaling thresholds and limits + +## Quick Start + +```python +from sage.apps.auto_scaling_chat import run_auto_scaling_demo + +# Run default simulation +run_auto_scaling_demo() + +# Custom load profile +run_auto_scaling_demo( + duration=60, # seconds + base_rate=5, # min concurrent users + peak_rate=80, # max concurrent users + verbose=True, # show scaling events +) +``` + +## Command Line Usage + +```bash +# Default simulation (30s) +python -m sage.apps.auto_scaling_chat.pipeline + +# Custom parameters +python -m sage.apps.auto_scaling_chat.pipeline \ + --duration 60 \ + --base-rate 10 \ + --peak-rate 80 \ + --verbose + +# Quick test +python -m sage.apps.auto_scaling_chat.pipeline --duration 15 --peak-rate 40 +``` + +## Pipeline Architecture + +The system uses SAGE stream processing operators: + +```text +UserTrafficSource (BatchFunction) + โ†“ +AutoScaler (MapFunction) + โ†“ +LoadBalancer (MapFunction) + โ†“ +RequestProcessor (MapFunction) + โ†“ +MetricsCollector (SinkFunction) + โ†“ +ScalingEventsSink (SinkFunction) [optional] +``` + +### Operators + +- **UserTrafficSource**: Generates simulated user requests with varying load patterns +- **AutoScaler**: Makes scaling decisions based on load/server ratio +- **LoadBalancer**: Distributes requests across available servers (round-robin) +- **RequestProcessor**: Simulates request processing on servers +- **MetricsCollector**: Collects and displays performance metrics +- **ScalingEventsSink**: Logs all scaling events for analysis + +## Scaling Policy + +Default thresholds: + +- **Scale UP**: When average load per server > 30 users +- **Scale DOWN**: When average load per server < 10 users +- **Min servers**: 2 +- **Max servers**: 10 +- **Cooldown**: 5 seconds between scaling actions + +## Example Output + +```text +โšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšก + SAGE Auto-Scaling Chat System Demo +โšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšกโšก + +====================================================================== +๐Ÿ“‹ Configuration +====================================================================== + Simulation duration: 30s + Base load: 5 concurrent users + Peak load: 50 concurrent users + Verbose logging: True +====================================================================== + +๐Ÿš€ Starting auto-scaling simulation... + +โš ๏ธ Scaling Threshold Reached! Load: 35, Scaling up to 3 servers +๐Ÿ“Š Load: 20 users | Servers: 2 | Requests: 10 +๐Ÿ“Š Load: 35 users | Servers: 3 | Requests: 20 +โš ๏ธ Scaling Threshold Reached! Load: 48, Scaling up to 4 servers +๐Ÿ“Š Load: 50 users | Servers: 4 | Requests: 30 + +====================================================================== +๐Ÿ“Š Auto-Scaling System Metrics +====================================================================== + Total requests processed: 245 + Total duration: 30.15s + Average throughput: 8.13 req/s + Peak load: 50 concurrent users + Peak servers: 4 + Scaling events: 5 + Average load: 28.3 users + Average servers: 3.2 +====================================================================== + +๐Ÿ”„ Scaling Events: + 1. ๐Ÿ“ˆ SCALE_UP: 3 servers (load: 35) + 2. ๐Ÿ“ˆ SCALE_UP: 4 servers (load: 48) + 3. ๐Ÿ“‰ SCALE_DOWN: 3 servers (load: 18) + 4. ๐Ÿ“‰ SCALE_DOWN: 2 servers (load: 8) +``` + +## Use Cases + +- **Cloud Applications**: Elastic scaling for web applications +- **Chat Systems**: Handle variable user loads efficiently +- **API Gateways**: Auto-scale based on request rates +- **Gaming Servers**: Match-making and lobby systems +- **Streaming Services**: Handle peak viewing times + +## Configuration Options + +Customize scaling behavior: + +```python +from sage.apps.auto_scaling_chat.operators import AutoScaler + +# Custom scaler with different thresholds +scaler = AutoScaler( + scale_up_threshold=25, # Scale up earlier + scale_down_threshold=5, # Scale down more aggressively + min_servers=1, # Allow single server + max_servers=20, # Allow more servers +) +``` + +## Testing + +Run the example script: + +```bash +python examples/apps/run_auto_scaling_chat.py +``` + +Or run tests: + +```bash +python -m pytest packages/sage-apps/tests/auto_scaling_chat/ -v +``` + +## Performance Metrics + +The system tracks: + +- Total requests processed +- Average throughput (requests/second) +- Peak concurrent load +- Server utilization +- Scaling event frequency +- Average response time + +## Dependencies + +- Python 3.8+ +- SAGE framework (`isage>=0.3.0`) +- Standard library only + +## Future Enhancements + +- Integration with real cloud providers (AWS, GCP, Azure) +- Predictive scaling using machine learning +- Cost optimization algorithms +- Multi-region deployment +- Advanced load balancing (weighted, least-connections) +- Health checks and failure recovery +- Integration with monitoring systems (Prometheus, Grafana) diff --git a/apps/src/sage/apps/auto_scaling_chat/__init__.py b/apps/src/sage/apps/auto_scaling_chat/__init__.py new file mode 100644 index 0000000..9b8b298 --- /dev/null +++ b/apps/src/sage/apps/auto_scaling_chat/__init__.py @@ -0,0 +1,20 @@ +""" +Auto-Scaling Chat System + +An intelligent chat system demonstrating SAGE's resource optimization capabilities +through automatic scaling based on user load. Shows cloud infrastructure elasticity +with load balancing and resource monitoring. + +Built on SAGE framework using stream processing operators. + +Features: +- Simulated user traffic generation +- Automatic scaling based on load thresholds +- Load balancing across server instances +- Resource monitoring and metrics +- Visual feedback on scaling decisions +""" + +from .pipeline import run_auto_scaling_demo + +__all__ = ["run_auto_scaling_demo"] diff --git a/apps/src/sage/apps/auto_scaling_chat/operators.py b/apps/src/sage/apps/auto_scaling_chat/operators.py new file mode 100644 index 0000000..b0a6fdc --- /dev/null +++ b/apps/src/sage/apps/auto_scaling_chat/operators.py @@ -0,0 +1,318 @@ +""" +SAGE Operators for Auto-Scaling Chat System + +Implements operators for simulated chat system with auto-scaling capabilities. +""" + +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + + +@dataclass +class UserRequest: + """Represents a user chat request.""" + + request_id: int + user_id: int + timestamp: str + message: str + processing_time: float = 0.0 + server_id: int | None = None + + +class UserTrafficSource(BatchFunction): + """ + BatchFunction source that generates simulated user traffic. + + Simulates variable user load with peaks and valleys. + """ + + # Probability scaling constant for request generation + PROBABILITY_SCALE = 100.0 + + def __init__( + self, + duration: int = 30, + base_rate: int = 5, + peak_rate: int = 50, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.duration = duration + self.base_rate = base_rate + self.peak_rate = peak_rate + self.start_time = time.time() + self.request_count = 0 + + def execute(self) -> dict[str, Any] | None: + """Generate user requests based on current load pattern.""" + elapsed = time.time() - self.start_time + + if elapsed >= self.duration: + return None + + # Simulate traffic pattern: gradual increase, peak, then decrease + progress = elapsed / self.duration + if progress < 0.3: + # Ramp up phase + rate = self.base_rate + (self.peak_rate - self.base_rate) * (progress / 0.3) + elif progress < 0.7: + # Peak phase + rate = self.peak_rate + else: + # Ramp down phase + rate = self.peak_rate - (self.peak_rate - self.base_rate) * ((progress - 0.7) / 0.3) + + # Determine if we should emit a request based on rate + # Higher rate = more requests per second + probability = rate / self.PROBABILITY_SCALE + if random.random() < probability: + self.request_count += 1 + return { + "request_id": self.request_count, + "user_id": random.randint(1, 1000), + "timestamp": datetime.now().isoformat(), + "message": f"User message #{self.request_count}", + "current_load": int(rate), + } + + # Small delay to control emission rate + time.sleep(0.05) + return None + + +class LoadBalancer(MapFunction): + """ + MapFunction that distributes requests across available servers. + + Implements simple round-robin load balancing. + """ + + def __init__(self, initial_servers: int = 2, **kwargs) -> None: + super().__init__(**kwargs) + self.num_servers = initial_servers + self.current_server = 0 + self.server_loads: dict[int, int] = dict.fromkeys(range(initial_servers), 0) + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Assign request to a server.""" + # Round-robin assignment + server_id = self.current_server + self.current_server = (self.current_server + 1) % self.num_servers + + # Update server load + self.server_loads[server_id] = self.server_loads.get(server_id, 0) + 1 + + # Assign to request + data["server_id"] = server_id + data["num_servers"] = self.num_servers + + return data + + def scale_servers(self, new_count: int) -> None: + """Scale number of servers.""" + old_count = self.num_servers + self.num_servers = new_count + + # Initialize new servers + for i in range(old_count, new_count): + self.server_loads[i] = 0 + + self.logger.info(f"Scaled servers: {old_count} โ†’ {new_count}") + + +class AutoScaler(MapFunction): + """ + MapFunction that makes auto-scaling decisions based on load. + """ + + def __init__( + self, + scale_up_threshold: int = 30, + scale_down_threshold: int = 10, + min_servers: int = 2, + max_servers: int = 10, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.scale_up_threshold = scale_up_threshold + self.scale_down_threshold = scale_down_threshold + self.min_servers = min_servers + self.max_servers = max_servers + self.current_servers = min_servers + self.last_scale_time = time.time() + self.cooldown = 5.0 # seconds + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Make scaling decision based on current load.""" + current_load = data.get("current_load", 0) + now = time.time() + + # Check if we're in cooldown period + if now - self.last_scale_time < self.cooldown: + data["scaling_action"] = "cooldown" + return data + + # Calculate average load per server + avg_load_per_server = current_load / self.current_servers if self.current_servers > 0 else 0 + + scaling_action = "none" + + # Scale up if load is high + if ( + avg_load_per_server > self.scale_up_threshold + and self.current_servers < self.max_servers + ): + new_servers = min(self.current_servers + 1, self.max_servers) + scaling_action = "scale_up" + self.current_servers = new_servers + self.last_scale_time = now + self.logger.warning( + f"โš ๏ธ Scaling Threshold Reached! " + f"Load: {current_load}, Scaling up to {new_servers} servers" + ) + + # Scale down if load is low + elif ( + avg_load_per_server < self.scale_down_threshold + and self.current_servers > self.min_servers + ): + new_servers = max(self.current_servers - 1, self.min_servers) + scaling_action = "scale_down" + self.current_servers = new_servers + self.last_scale_time = now + self.logger.info(f"Scaling down to {new_servers} servers (low load)") + + data["scaling_action"] = scaling_action + data["total_servers"] = self.current_servers + data["avg_load_per_server"] = avg_load_per_server + + return data + + +class RequestProcessor(MapFunction): + """ + MapFunction that simulates request processing on servers. + """ + + def __init__(self, processing_time: float = 0.1, **kwargs) -> None: + super().__init__(**kwargs) + self.processing_time = processing_time + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Process request.""" + # Simulate processing time + time.sleep(random.uniform(0, self.processing_time)) + + data["processing_time"] = self.processing_time + data["processed_at"] = datetime.now().isoformat() + + return data + + +class MetricsCollector(SinkFunction): + """ + SinkFunction that collects and displays system metrics. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.total_requests = 0 + self.scaling_events = 0 + self.peak_servers = 0 + self.peak_load = 0 + self.start_time = time.time() + self.load_history: list[int] = [] + self.server_history: list[int] = [] + + def execute(self, data: dict[str, Any]) -> None: + """Collect metrics from requests.""" + self.total_requests += 1 + + current_load = data.get("current_load", 0) + num_servers = data.get("total_servers", 0) + scaling_action = data.get("scaling_action", "none") + + # Update peak metrics + self.peak_load = max(self.peak_load, current_load) + self.peak_servers = max(self.peak_servers, num_servers) + + # Track scaling events + if scaling_action in ["scale_up", "scale_down"]: + self.scaling_events += 1 + + # Record history + self.load_history.append(current_load) + self.server_history.append(num_servers) + + # Log every 10 requests + if self.total_requests % 10 == 0: + self.logger.info( + f"๐Ÿ“Š Load: {current_load:3d} users | " + f"Servers: {num_servers:2d} | " + f"Requests: {self.total_requests:4d}" + ) + + def close(self) -> None: + """Display final metrics summary.""" + elapsed = time.time() - self.start_time + + print("\n" + "=" * 70) + print("๐Ÿ“Š Auto-Scaling System Metrics") + print("=" * 70) + print(f" Total requests processed: {self.total_requests}") + print(f" Total duration: {elapsed:.2f}s") + print(f" Average throughput: {self.total_requests / elapsed:.2f} req/s") + print(f" Peak load: {self.peak_load} concurrent users") + print(f" Peak servers: {self.peak_servers}") + print(f" Scaling events: {self.scaling_events}") + + if self.load_history: + avg_load = sum(self.load_history) / len(self.load_history) + print(f" Average load: {avg_load:.1f} users") + + if self.server_history: + avg_servers = sum(self.server_history) / len(self.server_history) + print(f" Average servers: {avg_servers:.1f}") + + print("=" * 70 + "\n") + + +class ScalingEventsSink(SinkFunction): + """ + SinkFunction that logs all scaling events. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.events: list[dict[str, Any]] = [] + + def execute(self, data: dict[str, Any]) -> None: + """Log scaling events.""" + scaling_action = data.get("scaling_action", "none") + if scaling_action in ["scale_up", "scale_down"]: + event = { + "action": scaling_action, + "servers": data.get("total_servers", 0), + "load": data.get("current_load", 0), + "timestamp": data.get("timestamp", ""), + } + self.events.append(event) + + def close(self) -> None: + """Display scaling events log.""" + if self.events: + print("\n๐Ÿ”„ Scaling Events:") + for i, event in enumerate(self.events, 1): + action_icon = "๐Ÿ“ˆ" if event["action"] == "scale_up" else "๐Ÿ“‰" + print( + f" {i}. {action_icon} {event['action'].upper()}: " + f"{event['servers']} servers (load: {event['load']})" + ) diff --git a/apps/src/sage/apps/auto_scaling_chat/pipeline.py b/apps/src/sage/apps/auto_scaling_chat/pipeline.py new file mode 100644 index 0000000..f5d4b3d --- /dev/null +++ b/apps/src/sage/apps/auto_scaling_chat/pipeline.py @@ -0,0 +1,170 @@ +""" +Auto-Scaling Chat System Pipeline + +Main pipeline implementation using SAGE operators for auto-scaling demonstration. +""" + +from __future__ import annotations + +from sage.foundation import CustomLogger +from sage.runtime import LocalEnvironment + +from .operators import ( + AutoScaler, + LoadBalancer, + MetricsCollector, + RequestProcessor, + ScalingEventsSink, + UserTrafficSource, +) + + +def run_auto_scaling_demo( + duration: int = 30, + base_rate: int = 5, + peak_rate: int = 50, + verbose: bool = False, +) -> None: + """ + Run the auto-scaling chat system demonstration. + + Args: + duration: Simulation duration in seconds + base_rate: Base user load (concurrent users) + peak_rate: Peak user load (concurrent users) + verbose: Enable verbose logging + + Example: + >>> from sage.apps.auto_scaling_chat import run_auto_scaling_demo + >>> run_auto_scaling_demo(duration=30, peak_rate=50) + """ + print("\n" + "โšก" * 35) + print(" " * 10 + "SAGE Auto-Scaling Chat System Demo") + print("โšก" * 35) + print() + print("=" * 70) + print("๐Ÿ“‹ Configuration") + print("=" * 70) + print(f" Simulation duration: {duration}s") + print(f" Base load: {base_rate} concurrent users") + print(f" Peak load: {peak_rate} concurrent users") + print(f" Verbose logging: {verbose}") + print("=" * 70) + print() + + print("=" * 70) + print("๐ŸŽฏ System Behavior") + print("=" * 70) + print(" Phase 1 (0-30%): Gradual load increase") + print(" Phase 2 (30-70%): Peak load period") + print(" Phase 3 (70-100%): Load decrease") + print() + print(" Scaling Policy:") + print(" - Scale UP when avg load/server > 30 users") + print(" - Scale DOWN when avg load/server < 10 users") + print(" - Min servers: 2, Max servers: 10") + print("=" * 70) + print() + + # Create SAGE environment + env = LocalEnvironment("auto_scaling_chat") + + # Build pipeline: + # 1. UserTrafficSource: Generate simulated user requests + # 2. AutoScaler: Make scaling decisions based on load + # 3. LoadBalancer: Distribute requests across servers + # 4. RequestProcessor: Process requests on servers + # 5. MetricsCollector: Collect and display metrics + # 6. ScalingEventsSink: Log scaling events + pipeline = ( + env.from_batch( + UserTrafficSource, + duration=duration, + base_rate=base_rate, + peak_rate=peak_rate, + ) + .map(AutoScaler, scale_up_threshold=30, scale_down_threshold=10) + .map(LoadBalancer, initial_servers=2) + .map(RequestProcessor, processing_time=0.05) + .sink(MetricsCollector) + ) + + if verbose: + pipeline.sink(ScalingEventsSink) + + # Execute pipeline + print("๐Ÿš€ Starting auto-scaling simulation...") + print() + env.submit(autostop=True) + + print("\nโšก" * 35) + print(" " * 20 + "Demo completed!") + print("โšก" * 35 + "\n") + + +def main(): + """Main entry point for command-line usage.""" + import argparse + + parser = argparse.ArgumentParser( + description="SAGE Auto-Scaling Chat System - Elastic resource management demo", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run default simulation (30s) + python -m sage.apps.auto_scaling_chat.pipeline + + # Short simulation with high peak + python -m sage.apps.auto_scaling_chat.pipeline --duration 20 --peak-rate 80 + + # Long simulation with moderate load + python -m sage.apps.auto_scaling_chat.pipeline --duration 60 --peak-rate 40 + + # Verbose mode with scaling events + python -m sage.apps.auto_scaling_chat.pipeline --verbose + """, + ) + + parser.add_argument( + "--duration", + "-d", + type=int, + default=30, + help="Simulation duration in seconds (default: 30)", + ) + + parser.add_argument( + "--base-rate", + "-b", + type=int, + default=5, + help="Base user load (default: 5)", + ) + + parser.add_argument( + "--peak-rate", + "-p", + type=int, + default=50, + help="Peak user load (default: 50)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + # Run demo + run_auto_scaling_demo( + duration=args.duration, + base_rate=args.base_rate, + peak_rate=args.peak_rate, + verbose=args.verbose, + ) + + +if __name__ == "__main__": + main() diff --git a/apps/src/sage/apps/literature_report_assistant/__init__.py b/apps/src/sage/apps/literature_report_assistant/__init__.py new file mode 100644 index 0000000..84454ee --- /dev/null +++ b/apps/src/sage/apps/literature_report_assistant/__init__.py @@ -0,0 +1,29 @@ +"""Literature Report Assistant. + +A lightweight assistant that retrieves recent papers, ranks them by topic relevance, +and generates a readable markdown report. +""" + +from .pipeline import ( + LiteraturePaper, + PaperRecommendation, + ResearchProfile, + generate_literature_report, + generate_personalized_recommendation_report, + preprocess_paper_stream, + rank_papers, + recommend_papers, + search_arxiv_papers, +) + +__all__ = [ + "LiteraturePaper", + "PaperRecommendation", + "ResearchProfile", + "generate_literature_report", + "generate_personalized_recommendation_report", + "preprocess_paper_stream", + "rank_papers", + "recommend_papers", + "search_arxiv_papers", +] diff --git a/apps/src/sage/apps/literature_report_assistant/pipeline.py b/apps/src/sage/apps/literature_report_assistant/pipeline.py new file mode 100644 index 0000000..182b1ae --- /dev/null +++ b/apps/src/sage/apps/literature_report_assistant/pipeline.py @@ -0,0 +1,242 @@ +"""Pipeline for literature search and report generation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable +from urllib.parse import quote_plus + +import requests +from bs4 import BeautifulSoup + +ARXIV_API = "http://export.arxiv.org/api/query" + + +@dataclass(frozen=True) +class LiteraturePaper: + """Single paper item used by the report assistant.""" + + title: str + summary: str + url: str + published: str + + +@dataclass(frozen=True) +class ResearchProfile: + """User profile for personalized paper recommendation.""" + + interests: list[str] + keywords: list[str] + + +@dataclass(frozen=True) +class PaperRecommendation: + """A scored recommendation item with explanation.""" + + paper: LiteraturePaper + score: float + reason: str + + +def search_arxiv_papers( + topic: str, max_results: int = 10, timeout: int = 20 +) -> list[LiteraturePaper]: + """Search arXiv papers by topic. + + Falls back to an empty list on transient HTTP/XML parsing errors to keep this + assistant usable in offline/example environments. + """ + query = quote_plus(topic.strip()) + request_url = ( + f"{ARXIV_API}?search_query=all:{query}&start=0&max_results={max_results}" + "&sortBy=submittedDate&sortOrder=descending" + ) + + try: + response = requests.get(request_url, timeout=timeout) + response.raise_for_status() + except requests.RequestException: + return [] + + soup = BeautifulSoup(response.text, "xml") + entries = soup.find_all("entry") + + papers: list[LiteraturePaper] = [] + for entry in entries: + title = (entry.find("title").text or "").strip() if entry.find("title") else "" + summary = (entry.find("summary").text or "").strip() if entry.find("summary") else "" + link = entry.find("id") + published = entry.find("published") + papers.append( + LiteraturePaper( + title=title, + summary=summary, + url=(link.text.strip() if link else ""), + published=(published.text.strip() if published else ""), + ) + ) + + return papers + + +def rank_papers( + papers: Iterable[LiteraturePaper], topic: str, top_k: int = 5 +) -> list[LiteraturePaper]: + """Rank papers with a simple keyword overlap score.""" + topic_tokens = {token.lower() for token in topic.split() if token.strip()} + if not topic_tokens: + return list(papers)[:top_k] + + scored: list[tuple[float, LiteraturePaper]] = [] + for paper in papers: + text = f"{paper.title} {paper.summary}".lower() + overlap = sum(1 for token in topic_tokens if token in text) + length_penalty = max(len(paper.summary), 1) + score = overlap + min(0.2, 200.0 / length_penalty) + scored.append((score, paper)) + + scored.sort(key=lambda item: item[0], reverse=True) + return [paper for _, paper in scored[:top_k]] + + +def preprocess_paper_stream(papers: Iterable[LiteraturePaper]) -> list[LiteraturePaper]: + """Normalize and deduplicate paper stream records.""" + normalized: list[LiteraturePaper] = [] + seen_keys: set[str] = set() + + for paper in papers: + title = " ".join(paper.title.split()) + summary = " ".join(paper.summary.split()) + url = paper.url.strip() + key = (url or title).lower() + if not key or key in seen_keys: + continue + seen_keys.add(key) + normalized.append( + LiteraturePaper( + title=title, + summary=summary, + url=url, + published=paper.published.strip(), + ) + ) + + return normalized + + +def recommend_papers( + papers: Iterable[LiteraturePaper], + profile: ResearchProfile, + top_k: int = 5, +) -> list[PaperRecommendation]: + """Generate personalized paper recommendations with reasons.""" + profile_tokens = { + token.lower() + for token in [*profile.interests, *profile.keywords] + if isinstance(token, str) and token.strip() + } + + if not profile_tokens: + return [ + PaperRecommendation(paper=paper, score=0.0, reason="ๆœชๆไพ›็”ปๅƒๅ…ณ้”ฎ่ฏ๏ผŒๆŒ‰ๅŽŸๅง‹้กบๅบ่ฟ”ๅ›ž") + for paper in list(papers)[:top_k] + ] + + recommendations: list[PaperRecommendation] = [] + for paper in papers: + text = f"{paper.title} {paper.summary}".lower() + matched_tokens = [token for token in sorted(profile_tokens) if token in text] + if not matched_tokens: + continue + + coverage = len(matched_tokens) + freshness_bonus = 0.1 if paper.published else 0.0 + score = float(coverage) + freshness_bonus + reason = f"ๅŒน้…ๅ…ณ้”ฎ่ฏ: {', '.join(matched_tokens[:4])}" + recommendations.append(PaperRecommendation(paper=paper, score=score, reason=reason)) + + recommendations.sort(key=lambda item: item.score, reverse=True) + return recommendations[:top_k] + + +def generate_literature_report(topic: str, papers: list[LiteraturePaper]) -> str: + """Generate a markdown literature reading report.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [ + f"# ๆ–‡็Œฎ้˜…่ฏปๆŠฅๅ‘Š๏ผš{topic}", + "", + f"็”Ÿๆˆๆ—ถ้—ด๏ผš{now}", + f"ๅ€™้€‰ๆ–‡็Œฎๆ•ฐ๏ผš{len(papers)}", + "", + "## ๆ ธๅฟƒๅ‘็Žฐ", + ] + + if not papers: + lines.extend( + [ + "- ๆœฌๆฌกๆœชๆฃ€็ดขๅˆฐๅฏ็”จๆ–‡็Œฎ๏ผˆๅฏ่ƒฝๆ˜ฏ็ฝ‘็ปœๅ—้™ๆˆ–ไธป้ข˜่ฟ‡็ช„๏ผ‰ใ€‚", + "- ๅปบ่ฎฎๆ‰ฉๅฑ•ๅ…ณ้”ฎ่ฏๆˆ–็จๅŽ้‡่ฏ•ใ€‚", + "", + ] + ) + return "\n".join(lines) + + for index, paper in enumerate(papers, start=1): + brief = paper.summary.replace("\n", " ").strip() + if len(brief) > 260: + brief = f"{brief[:257]}..." + lines.extend( + [ + f"### {index}. {paper.title}", + f"- ๅ‘่กจๆ—ถ้—ด๏ผš{paper.published or 'ๆœช็Ÿฅ'}", + f"- ้“พๆŽฅ๏ผš{paper.url or 'N/A'}", + f"- ๆ‘˜่ฆ๏ผš{brief}", + "", + ] + ) + + lines.extend( + [ + "## ๅปบ่ฎฎ้˜…่ฏป้กบๅบ", + "- ๅ…ˆ้˜…่ฏปๆŽ’ๅ้ ๅ‰็š„ 1-2 ็ฏ‡๏ผŒๆŠŠๆกๆœ€ๆ–ฐๆ–นๅ‘ใ€‚", + "- ๅ†ๆŒ‰ๆ–นๆณ•ๆˆ–ๆ•ฐๆฎ้›†็›ธไผผๅบฆๆŒ‘้€‰ๅŽ็ปญๆ–‡็Œฎๆทฑๅ…ฅใ€‚", + ] + ) + + return "\n".join(lines) + + +def generate_personalized_recommendation_report( + topic: str, + recommendations: list[PaperRecommendation], +) -> str: + """Generate markdown report for personalized recommendations.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [ + f"# ไธชๆ€งๅŒ–ๆ–‡็ŒฎๆŽจ่ๆŠฅๅ‘Š๏ผš{topic}", + "", + f"็”Ÿๆˆๆ—ถ้—ด๏ผš{now}", + f"ๆŽจ่ๆก็›ฎๆ•ฐ๏ผš{len(recommendations)}", + "", + "## ๆŽจ่็ป“ๆžœ", + ] + + if not recommendations: + lines.append("- ๆœชๆ‰พๅˆฐไธŽๅฝ“ๅ‰็”ปๅƒๅŒน้…็š„ๆ–‡็Œฎ๏ผŒๅปบ่ฎฎๆ‰ฉๅคงๅ…ด่ถฃๅ…ณ้”ฎ่ฏ่Œƒๅ›ดใ€‚") + return "\n".join(lines) + + for index, item in enumerate(recommendations, start=1): + lines.extend( + [ + f"### {index}. {item.paper.title}", + f"- ๅˆ†ๆ•ฐ๏ผš{item.score:.2f}", + f"- ๆŽจ่็†็”ฑ๏ผš{item.reason}", + f"- ๅ‘่กจๆ—ถ้—ด๏ผš{item.paper.published or 'ๆœช็Ÿฅ'}", + f"- ้“พๆŽฅ๏ผš{item.paper.url or 'N/A'}", + "", + ] + ) + + return "\n".join(lines) diff --git a/apps/src/sage/apps/medical_diagnosis/README.md b/apps/src/sage/apps/medical_diagnosis/README.md new file mode 100644 index 0000000..ebcf844 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/README.md @@ -0,0 +1,217 @@ +# Medical Diagnosis Application + +SAGE ๅŒป็–—่ฏŠๆ–ญๅบ”็”จ็คบไพ‹ - ๅฑ•็คบๅฆ‚ไฝ•ไฝฟ็”จ SAGE ๆž„ๅปบๅคšๆ™บ่ƒฝไฝ“ๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸใ€‚ + +## ๐Ÿ“‹ ๆฆ‚่ฟฐ + +่ฟ™ไธชๅบ”็”จๅฑ•็คบไบ†ๅฆ‚ไฝ•ไฝฟ็”จ SAGE ๆก†ๆžถๆž„ๅปบไธ€ไธชๅไฝœๅผๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸ๏ผŒๅ…ถไธญๅคšไธชไธ“ไธšๅŒป็”Ÿๆ™บ่ƒฝไฝ“ๅๅŒๅทฅไฝœ๏ผŒๅˆ†ๆžๆ‚ฃ่€…็—‡็Šถๅนถๆไพ›่ฏŠๆ–ญๅปบ่ฎฎใ€‚ + +## ๐Ÿ—๏ธ ๆžถๆž„ + +``` +medical_diagnosis/ +โ”œโ”€โ”€ agents/ # ๆ™บ่ƒฝไฝ“ๅฎšไน‰ +โ”‚ โ”œโ”€โ”€ doctor.py # ไธปๆฒปๅŒป็”Ÿๆ™บ่ƒฝไฝ“ +โ”‚ โ””โ”€โ”€ specialist.py # ไธ“็ง‘ๅŒป็”Ÿๆ™บ่ƒฝไฝ“ +โ”œโ”€โ”€ config/ # ้…็ฝฎๆ–‡ไปถ +โ”‚ โ””โ”€โ”€ agents.yaml # ๆ™บ่ƒฝไฝ“้…็ฝฎ +โ”œโ”€โ”€ tools/ # ๅทฅๅ…ทๅ‡ฝๆ•ฐ +โ”‚ โ”œโ”€โ”€ medical_knowledge.py # ๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“ +โ”‚ โ””โ”€โ”€ symptom_analyzer.py # ็—‡็Šถๅˆ†ๆžๅทฅๅ…ท +โ”œโ”€โ”€ data/ # ๆ•ฐๆฎๆ–‡ไปถ +โ”‚ โ””โ”€โ”€ patient_cases/ # ๆ‚ฃ่€…ๆกˆไพ‹ +โ”œโ”€โ”€ scripts/ # ่„šๆœฌๆ–‡ไปถ +โ””โ”€โ”€ run_diagnosis.py # ไธป่ฟ่กŒ่„šๆœฌ +``` + +## ๐Ÿš€ ๅฟซ้€Ÿๅผ€ๅง‹ + +### ๅฎ‰่ฃ…ไพ่ต– + +```bash +# ๅฎ‰่ฃ… SAGE +pip install isage-apps + +# ๆˆ–ไปŽๆบ็ ๅฎ‰่ฃ… +cd packages/sage-apps +pip install -e . +``` + +### ๅ‡†ๅค‡ๆ•ฐๆฎ + +```bash +# ่ฟ่กŒๆ•ฐๆฎๅ‡†ๅค‡่„šๆœฌ +cd src/sage/apps/medical_diagnosis +./setup_data.sh +``` + +### ่ฟ่กŒ่ฏŠๆ–ญ + +```bash +# ๅ•ไธชๆกˆไพ‹่ฏŠๆ–ญ +python run_diagnosis.py --case patient_001 + +# ๆ‰น้‡่ฏŠๆ–ญ +python run_diagnosis.py --batch --input-dir data/patient_cases/ + +# ไบคไบ’ๅผ่ฏŠๆ–ญ +python run_diagnosis.py --interactive +``` + +## ๐Ÿ“Š ็คบไพ‹ + +### ๅ•ไธชๆ‚ฃ่€…่ฏŠๆ–ญ + +```python +from sage.apps.medical_diagnosis import MedicalDiagnosisSystem + +# ๅˆ›ๅปบ่ฏŠๆ–ญ็ณป็ปŸ +system = MedicalDiagnosisSystem() + +# ๆ‚ฃ่€…็—‡็Šถ +patient_data = { + "age": 45, + "gender": "male", + "symptoms": ["ๅคด็—›", "ๅ‘็ƒง", "ๅ’ณๅ—ฝ"], + "duration": "3ๅคฉ", + "medical_history": ["้ซ˜่ก€ๅŽ‹"], +} + +# ๆ‰ง่กŒ่ฏŠๆ–ญ +result = system.diagnose(patient_data) + +# ๆŸฅ็œ‹็ป“ๆžœ +print(f"ๅˆๆญฅ่ฏŠๆ–ญ: {result['diagnosis']}") +print(f"ๅปบ่ฎฎๆฃ€ๆŸฅ: {result['recommended_tests']}") +print(f"ๆฒป็–—ๆ–นๆกˆ: {result['treatment_plan']}") +``` + +### ๆ‰น้‡ๅค„็† + +```python +from sage.apps.medical_diagnosis import run_batch_diagnosis + +# ๆ‰น้‡่ฏŠๆ–ญ +results = run_batch_diagnosis(input_dir="data/patient_cases/", output_dir="results/") + +# ็”ŸๆˆๆŠฅๅ‘Š +for patient_id, diagnosis in results.items(): + print(f"ๆ‚ฃ่€… {patient_id}: {diagnosis['summary']}") +``` + +## ๐ŸŽฏ ๅŠŸ่ƒฝ็‰นๆ€ง + +- **ๅคšๆ™บ่ƒฝไฝ“ๅไฝœ**: ไธปๆฒปๅŒป็”Ÿๅ’Œไธ“็ง‘ๅŒป็”ŸๅๅŒ่ฏŠๆ–ญ +- **็Ÿฅ่ฏ†ๅบ“้›†ๆˆ**: ๆ•ดๅˆๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“ๅ’Œ่ฏŠๆ–ญๆŒ‡ๅ— +- **็—‡็Šถๅˆ†ๆž**: ่‡ชๅŠจๅˆ†ๆžๆ‚ฃ่€…็—‡็Šถๅนถ็”Ÿๆˆ่ฏŠๆ–ญๅ‡่ฎพ +- **ๆฒป็–—ๅปบ่ฎฎ**: ๅŸบไบŽ่ฏŠๆ–ญ็ป“ๆžœๆไพ›ๆฒป็–—ๆ–นๆกˆ +- **ๆ‰น้‡ๅค„็†**: ๆ”ฏๆŒๆ‰น้‡ๅค„็†ๅคšไธชๆ‚ฃ่€…ๆกˆไพ‹ +- **ๅฏ่ง†ๅŒ–็ป“ๆžœ**: ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Šๅ’Œๅฏ่ง†ๅŒ–ๅ›พ่กจ + +## ๐Ÿ”ง ้…็ฝฎ + +็ผ–่พ‘ `config/agents.yaml` ๆฅ้…็ฝฎๆ™บ่ƒฝไฝ“่กŒไธบ๏ผš + +```yaml +agents: + - name: primary_doctor + role: ไธปๆฒปๅŒป็”Ÿ + llm_config: + model: gpt-4 + temperature: 0.7 + + - name: cardiologist + role: ๅฟƒ่„็ง‘ไธ“ๅฎถ + llm_config: + model: gpt-4 + temperature: 0.5 +``` + +## ๐Ÿ“ ๆ•ฐๆฎๆ ผๅผ + +### ่พ“ๅ…ฅๆ ผๅผ + +ๆ‚ฃ่€…ๆกˆไพ‹ๅบ”ไฝฟ็”จ JSON ๆ ผๅผ๏ผš + +```json +{ + "patient_id": "P001", + "age": 45, + "gender": "male", + "chief_complaint": "่ƒธ็—›", + "symptoms": [ + {"name": "่ƒธ็—›", "severity": "severe", "duration": "2ๅฐๆ—ถ"}, + {"name": "ๅ‘ผๅธๅ›ฐ้šพ", "severity": "moderate", "duration": "30ๅˆ†้’Ÿ"} + ], + "vital_signs": { + "blood_pressure": "140/90", + "heart_rate": 95, + "temperature": 37.2 + }, + "medical_history": ["้ซ˜่ก€ๅŽ‹", "็ณ–ๅฐฟ็—…"] +} +``` + +### ่พ“ๅ‡บๆ ผๅผ + +่ฏŠๆ–ญ็ป“ๆžœ๏ผš + +```json +{ + "diagnosis": { + "primary": "ๆ€ฅๆ€งๅฟƒ่‚Œๆข—ๆญปๅฏ่ƒฝๆ€ง้ซ˜", + "differential": ["ไธ็จณๅฎšๅž‹ๅฟƒ็ปž็—›", "ไธปๅŠจ่„‰ๅคนๅฑ‚"], + "confidence": 0.85 + }, + "recommended_tests": [ + "ๅฟƒ็”ตๅ›พ", + "ๅฟƒ่‚Œ้…ถ่ฐฑ", + "ๅ† ็ŠถๅŠจ่„‰้€ ๅฝฑ" + ], + "treatment_plan": { + "immediate": ["ๆฐง็–—", "็ก้…ธ็”˜ๆฒน", "้˜ฟๅธๅŒนๆž—"], + "follow_up": ["ๅฟƒ่„่ถ…ๅฃฐ", "ๅ† ่„‰ไป‹ๅ…ฅ"] + }, + "reasoning": "ๅŸบไบŽๆ‚ฃ่€…็—‡็Šถใ€ไฝ“ๅพๅ’Œ็—…ๅฒ๏ผŒ่€ƒ่™‘ๆ€ฅๆ€งๅ† ่„‰็ปผๅˆๅพ..." +} +``` + +## ๐Ÿงช ๆต‹่ฏ• + +```bash +# ่ฟ่กŒๆ‰€ๆœ‰ๆต‹่ฏ• +pytest tests/medical_diagnosis/ + +# ่ฟ่กŒๅ•ไธชๆต‹่ฏ• +pytest tests/medical_diagnosis/test_diagnosis.py -v + +# ่ฟ่กŒๅธฆ่ฆ†็›–็އ็š„ๆต‹่ฏ• +pytest tests/medical_diagnosis/ --cov=sage.apps.medical_diagnosis +``` + +## ๐Ÿ“š ็›ธๅ…ณๆ–‡ๆกฃ + +- [SAGE ๆก†ๆžถๆ–‡ๆกฃ](../../docs/) +- [ๅคšๆ™บ่ƒฝไฝ“็ณป็ปŸๆŒ‡ๅ—](../../docs/guides/multi-agent.md) +- [ๅŒป็–— AI ๅบ”็”จๆœ€ไฝณๅฎž่ทต](../../docs/tutorials/medical-ai.md) + +## โš ๏ธ ๅ…่ดฃๅฃฐๆ˜Ž + +**้‡่ฆ**: ่ฟ™ๆ˜ฏไธ€ไธชๆผ”็คบๅบ”็”จ๏ผŒไป…็”จไบŽๆ•™ๅญฆๅ’Œ็ ”็ฉถ็›ฎ็š„ใ€‚**ไธๅบ”**็”จไบŽๅฎž้™…ๅŒป็–—่ฏŠๆ–ญใ€‚ไปปไฝ•ๅŒป็–—ๅ†ณ็ญ–้ƒฝๅบ”็”ฑไธ“ไธšๅŒป็–—ไบบๅ‘˜ๅšๅ‡บใ€‚ + +## ๐Ÿ“„ ่ฎธๅฏ่ฏ + +ๆœฌๅบ”็”จ้ตๅพช SAGE ้กน็›ฎ็š„ MIT ่ฎธๅฏ่ฏใ€‚ + +## ๐Ÿค ่ดก็Œฎ + +ๆฌข่ฟŽ่ดก็Œฎ๏ผ่ฏทๅ‚้˜… [CONTRIBUTING.md](../../../../CONTRIBUTING.md) ไบ†่งฃๅฆ‚ไฝ•ๅ‚ไธŽ้กน็›ฎใ€‚ + +## ๐Ÿ“ง ่”็ณปๆ–นๅผ + +- ้—ฎ้ข˜ๅ้ฆˆ: [GitHub Issues](https://github.com/intellistream/SAGE/issues) +- ่ฎจ่ฎบ: [GitHub Discussions](https://github.com/intellistream/SAGE/discussions) + +______________________________________________________________________ + +**ๆž„ๅปบไบŽ** [SAGE](https://github.com/intellistream/SAGE) - ไธ‹ไธ€ไปฃๆ™บ่ƒฝไฝ“ๆก†ๆžถ diff --git a/apps/src/sage/apps/medical_diagnosis/__init__.py b/apps/src/sage/apps/medical_diagnosis/__init__.py new file mode 100644 index 0000000..5289d58 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/__init__.py @@ -0,0 +1,44 @@ +""" +Medical Diagnosis Application - Lumbar Spine MRI Analysis + +A complete medical diagnosis system for analyzing lumbar spine MRI images +and generating diagnostic reports using multi-agent collaboration. + +Quick Start: + >>> from sage.apps.medical_diagnosis import DiagnosticAgent + >>> + >>> agent = DiagnosticAgent() + >>> result = agent.diagnose( + ... image_path="path/to/mri.jpg", + ... patient_info={"age": 45, "gender": "male", "symptoms": "back pain"} + ... ) + >>> print(result.report) + +Components: + - DiagnosticAgent: Main coordinator agent + - ImageAnalyzer: MRI image analysis agent + - ReportGenerator: Diagnostic report generation agent + - MedicalKnowledgeBase: Medical knowledge retrieval + +For full documentation, see README.md in this directory. +""" + +from .agents.diagnostic_agent import DiagnosisResult, DiagnosticAgent +from .agents.image_analyzer import ImageAnalyzer, ImageFeatures +from .agents.report_generator import DiagnosisReport, ReportGenerator +from .tools.knowledge_base import MedicalKnowledgeBase + +__all__ = [ + # Main Agent + "DiagnosticAgent", + "DiagnosisResult", + # Sub Agents + "ImageAnalyzer", + "ImageFeatures", + "ReportGenerator", + "DiagnosisReport", + # Tools + "MedicalKnowledgeBase", +] + +__version__ = "1.0.0" diff --git a/apps/src/sage/apps/medical_diagnosis/agents/__init__.py b/apps/src/sage/apps/medical_diagnosis/agents/__init__.py new file mode 100644 index 0000000..137f735 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/agents/__init__.py @@ -0,0 +1,12 @@ +"""ๅŒป็–—่ฏŠๆ–ญAgentๅŒ…""" + +from .diagnostic_agent import DiagnosisResult, DiagnosticAgent +from .image_analyzer import ImageAnalyzer +from .report_generator import ReportGenerator + +__all__ = [ + "DiagnosticAgent", + "DiagnosisResult", + "ImageAnalyzer", + "ReportGenerator", +] diff --git a/apps/src/sage/apps/medical_diagnosis/agents/diagnostic_agent.py b/apps/src/sage/apps/medical_diagnosis/agents/diagnostic_agent.py new file mode 100644 index 0000000..9ddd444 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/agents/diagnostic_agent.py @@ -0,0 +1,235 @@ +""" +ๅŒป็–—่ฏŠๆ–ญAgent - ไธปAgent +่ดŸ่ดฃๅ่ฐƒๅฝฑๅƒๅˆ†ๆžใ€็Ÿฅ่ฏ†ๆฃ€็ดขๅ’ŒๆŠฅๅ‘Š็”Ÿๆˆ +""" + +from pathlib import Path +from typing import Any + +import yaml + +# ๅฏผๅ…ฅ DiagnosisReport๏ผŒDiagnosisResult ไฝœไธบๅˆซๅไปฅไฟๆŒๅ‘ๅŽๅ…ผๅฎน +from .report_generator import DiagnosisReport + +# ๅ‘ๅŽๅ…ผๅฎนๅˆซๅ +DiagnosisResult = DiagnosisReport + + +class DiagnosticAgent: + """ + ่…ฐๆคŽMRI่ฏŠๆ–ญAgent + + ๅŠŸ่ƒฝ: + 1. ๆŽฅๆ”ถMRIๅฝฑๅƒๅ’Œๆ‚ฃ่€…ไฟกๆฏ + 2. ่ฐƒ็”จๅฝฑๅƒๅˆ†ๆžAgentๆๅ–็‰นๅพ + 3. ๆฃ€็ดข็›ธๅ…ณๅŒปๅญฆ็Ÿฅ่ฏ†ๅ’Œ็›ธไผผ็—…ไพ‹ + 4. ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Š + """ + + def __init__(self, config_path: str | None = None): + """ + ๅˆๅง‹ๅŒ–่ฏŠๆ–ญAgent + + Args: + config_path: ้…็ฝฎๆ–‡ไปถ่ทฏๅพ„ + """ + self.config = self._load_config(config_path) + self.image_analyzer = None + self.knowledge_base = None + self.report_generator = None + self._setup_components() + + def _load_config(self, config_path: str | None) -> dict: + """ๅŠ ่ฝฝ้…็ฝฎ""" + if config_path and Path(config_path).exists(): + with open(config_path, encoding="utf-8") as f: + return yaml.safe_load(f) + + # ้ป˜่ฎค้…็ฝฎ + return { + "agent": { + "name": "LumbarSpineDiagnosticAgent", + "version": "1.0.0", + "max_iterations": 5, + }, + "models": { + "vision_model": "Qwen/Qwen2-VL-7B-Instruct", + "llm_model": "Qwen/Qwen2.5-7B-Instruct", + "embedding_model": "BAAI/bge-large-zh-v1.5", + }, + "services": { + "sagellm": {"enabled": True, "gpu_memory_utilization": 0.9}, + "embedding": {"method": "hf", "cache_enabled": True}, + "vector_db": {"collection_name": "lumbar_spine_cases", "top_k": 5}, + }, + } + + def _setup_components(self): + """่ฎพ็ฝฎ็ป„ไปถ""" + from sage.apps.medical_diagnosis.agents.image_analyzer import ImageAnalyzer + from sage.apps.medical_diagnosis.agents.report_generator import ReportGenerator + from sage.apps.medical_diagnosis.tools.knowledge_base import MedicalKnowledgeBase + + # ๅˆๅง‹ๅŒ–ๅ„ไธช็ป„ไปถ + self.image_analyzer = ImageAnalyzer(self.config) + self.knowledge_base = MedicalKnowledgeBase(self.config) + self.report_generator = ReportGenerator(self.config) + + print("โœ… DiagnosticAgent ๅˆๅง‹ๅŒ–ๅฎŒๆˆ") + print(f" Vision Model: {self.config['models']['vision_model']}") + print(f" LLM Model: {self.config['models']['llm_model']}") + + def diagnose( + self, + image_path: str, + patient_info: dict[str, Any] | None = None, + verbose: bool = True, + ) -> DiagnosisResult: + """ + ๆ‰ง่กŒ่ฏŠๆ–ญ + + Args: + image_path: MRIๅฝฑๅƒ่ทฏๅพ„ + patient_info: ๆ‚ฃ่€…ไฟกๆฏ๏ผˆๅนด้พ„ใ€ๆ€งๅˆซใ€็—‡็Šถ็ญ‰๏ผ‰ + verbose: ๆ˜ฏๅฆๆ‰“ๅฐ่ฏฆ็ป†ไฟกๆฏ + + Returns: + DiagnosisResult: ่ฏŠๆ–ญ็ป“ๆžœ + """ + if verbose: + print(f"\n{'=' * 60}") + print("๐Ÿฅ ๅผ€ๅง‹่ฏŠๆ–ญๅˆ†ๆž") + print(f"{'=' * 60}") + print(f"๐Ÿ“„ ๅฝฑๅƒ่ทฏๅพ„: {image_path}") + if patient_info: + print(f"๐Ÿ‘ค ๆ‚ฃ่€…ไฟกๆฏ: {patient_info}") + + # Step 1: ๅฝฑๅƒๅˆ†ๆž + if verbose: + print("\n๐Ÿ“Š Step 1: ๅฝฑๅƒ็‰นๅพๆๅ–...") + + if not self.image_analyzer: + raise RuntimeError("Image analyzer not initialized") + image_features = self.image_analyzer.analyze(image_path) + + if verbose: + print(f" โœ“ ๆฃ€ๆต‹ๅˆฐ {len(image_features.get('vertebrae', []))} ไธชๆคŽไฝ“") + print(f" โœ“ ๆฃ€ๆต‹ๅˆฐ {len(image_features.get('discs', []))} ไธชๆคŽ้—ด็›˜") + if image_features.get("abnormalities"): + print(f" โš  ๅ‘็Žฐ {len(image_features['abnormalities'])} ๅค„ๅผ‚ๅธธ") + + # Step 2: ็Ÿฅ่ฏ†ๅบ“ๆฃ€็ดข + if verbose: + print("\n๐Ÿ” Step 2: ๆฃ€็ดข็›ธๅ…ณ็Ÿฅ่ฏ†ๅ’Œ็—…ไพ‹...") + + # ๆž„ๅปบๆŸฅ่ฏข + query = self._build_query(image_features, patient_info) + + # ๆฃ€็ดข็›ธไผผ็—…ไพ‹ + if not self.knowledge_base: + raise RuntimeError("Knowledge base not initialized") + similar_cases = self.knowledge_base.retrieve_similar_cases( + query=query, + image_features=image_features, + top_k=self.config["services"]["vector_db"]["top_k"], + ) + + if verbose: + print(f" โœ“ ๆฃ€็ดขๅˆฐ {len(similar_cases)} ไธช็›ธไผผ็—…ไพ‹") + + # ๆฃ€็ดขๅŒปๅญฆ็Ÿฅ่ฏ† + medical_knowledge = self.knowledge_base.retrieve_knowledge(query=query, top_k=3) + + # Step 3: ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Š + if verbose: + print("\n๐Ÿ“ Step 3: ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Š...") + + if not self.report_generator: + raise RuntimeError("Report generator not initialized") + diagnosis_result = self.report_generator.generate( + image_features=image_features, + patient_info=patient_info, + similar_cases=similar_cases, + medical_knowledge=medical_knowledge, + ) + + if verbose: + print(" โœ“ ๆŠฅๅ‘Š็”ŸๆˆๅฎŒๆˆ") + print(f"\n{'=' * 60}") + print("๐Ÿ“‹ ่ฏŠๆ–ญ็ป“ๆžœ") + print(f"{'=' * 60}") + print(f"\n{diagnosis_result.report}") + + return diagnosis_result + + def _build_query(self, image_features: dict, patient_info: dict | None) -> str: + """ๆž„ๅปบๆฃ€็ดขๆŸฅ่ฏข""" + query_parts = [] + + # ๆทปๅŠ ๅฝฑๅƒๅ‘็Žฐ + if image_features.get("abnormalities"): + findings = [a["description"] for a in image_features["abnormalities"]] + query_parts.append(f"ๅฝฑๅƒๅ‘็Žฐ: {', '.join(findings)}") + + # ๆทปๅŠ ๆ‚ฃ่€…็—‡็Šถ + if patient_info and "symptoms" in patient_info: + query_parts.append(f"็—‡็Šถ: {patient_info['symptoms']}") + + # ๆทปๅŠ ๅนด้พ„ไฟกๆฏ + if patient_info and "age" in patient_info: + age = patient_info["age"] + if age > 60: + query_parts.append("่€ๅนดๆ‚ฃ่€…้€€่กŒๆ€งๅ˜ๅŒ–") + elif age > 40: + query_parts.append("ไธญๅนด่…ฐๆคŽ็—…ๅ˜") + + return " ".join(query_parts) + + def batch_diagnose( + self, cases: list[dict[str, Any]], output_dir: str | None = None + ) -> list[DiagnosisResult]: + """ + ๆ‰น้‡่ฏŠๆ–ญ + + Args: + cases: ็—…ไพ‹ๅˆ—่กจ๏ผŒๆฏไธชๅŒ…ๅซ image_path ๅ’Œ patient_info + output_dir: ่พ“ๅ‡บ็›ฎๅฝ• + + Returns: + ่ฏŠๆ–ญ็ป“ๆžœๅˆ—่กจ + """ + results = [] + + print(f"\n๐Ÿฅ ๆ‰น้‡่ฏŠๆ–ญๅผ€ๅง‹ - ๅ…ฑ {len(cases)} ไธช็—…ไพ‹") + + for i, case in enumerate(cases, 1): + print(f"\nๅค„็†็—…ไพ‹ {i}/{len(cases)}") + + result = self.diagnose( + image_path=case["image_path"], + patient_info=case.get("patient_info"), + verbose=False, + ) + + results.append(result) + + # ไฟๅญ˜็ป“ๆžœ + if output_dir: + output_path = Path(output_dir) / f"case_{i:03d}_report.txt" + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(result.report) + + print("\nโœ… ๆ‰น้‡่ฏŠๆ–ญๅฎŒๆˆ๏ผ") + return results + + +if __name__ == "__main__": + # ๆต‹่ฏ•ไปฃ็  + agent = DiagnosticAgent() + + # ็คบไพ‹่ฏŠๆ–ญ + result = agent.diagnose( + image_path="data/medical/test/sample_mri.jpg", + patient_info={"age": 45, "gender": "male", "symptoms": "ไธ‹่ƒŒ้ƒจ็–ผ็—›๏ผŒๅทฆ่…ฟ้บปๆœจ"}, + ) diff --git a/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py b/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py new file mode 100644 index 0000000..056cabc --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/agents/image_analyzer.py @@ -0,0 +1,1196 @@ +""" +ๅฝฑๅƒๅˆ†ๆžAgent +่ดŸ่ดฃMRIๅฝฑๅƒ็š„็‰นๅพๆๅ–ๅ’Œๅˆๆญฅๅˆ†ๆž +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +try: + import torch + from PIL import Image as PILImage + from transformers import ( + AutoImageProcessor, + AutoModel, + CLIPModel, + CLIPProcessor, + ) + + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + torch = None + PILImage = None + AutoImageProcessor = None + AutoModel = None + CLIPModel = None + CLIPProcessor = None + +try: + from numpy.lib.stride_tricks import sliding_window_view +except ImportError: # pragma: no cover - older NumPy fallback + sliding_window_view = None # type: ignore[assignment] + + +DEFAULT_DISC_LEVELS = ["L1/L2", "L2/L3", "L3/L4", "L4/L5", "L5/S1"] + + +@dataclass +class ImageFeatures: + """ๅฝฑๅƒ็‰นๅพ""" + + vertebrae: list[dict[str, Any]] # ๆคŽไฝ“ไฟกๆฏ + discs: list[dict[str, Any]] # ๆคŽ้—ด็›˜ไฟกๆฏ + abnormalities: list[dict[str, Any]] # ๅผ‚ๅธธๅ‘็Žฐ + image_quality: float # ๅฝฑๅƒ่ดจ้‡่ฏ„ๅˆ† + image_embedding: np.ndarray | None = None # ๅฝฑๅƒๅตŒๅ…ฅๅ‘้‡ + + +class ImageAnalyzer: + """ + MRIๅฝฑๅƒๅˆ†ๆžๅ™จ + + ๅŠŸ่ƒฝ: + 1. ๅ›พๅƒ้ข„ๅค„็†ๅ’Œ่ดจ้‡่ฏ„ไผฐ + 2. ๆคŽไฝ“ๅ’ŒๆคŽ้—ด็›˜ๅˆ†ๅ‰ฒๅฎšไฝ + 3. ็—…ๅ˜ๅŒบๅŸŸๆฃ€ๆต‹ + 4. ๅฝฑๅƒ็‰นๅพๆๅ–ๅ’Œๅ‘้‡ๅŒ– + """ + + _LAPLACIAN_KERNEL = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32) + _SOBEL_X = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32) + _SOBEL_Y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32) + _BLUR_KERNEL = np.ones((3, 3), dtype=np.float32) / 9.0 + _DEFAULT_QUALITY_WEIGHTS = { + "contrast": 0.25, + "sharpness": 0.35, + "noise": 0.25, + "brightness": 0.15, + } + + def __init__(self, config: dict): + """ + ๅˆๅง‹ๅŒ–ๅฝฑๅƒๅˆ†ๆžๅ™จ + + Args: + config: ้…็ฝฎๅญ—ๅ…ธ + """ + self.config = config + self.vision_model = None + self.segmentation_model = None + self.segmenter_type = None + self.segmenter_device = "cpu" + self.segmentation_config: dict[str, Any] = {} + self.segmentation_enabled = False + self.disc_levels = DEFAULT_DISC_LEVELS.copy() + self.segmentation_thresholds: dict[str, float] = {} + self.pixel_spacing_mm = 180.0 + self.baseline_disc_height_mm = 9.0 + self._setup_models() + + def _setup_models(self): + """่ฎพ็ฝฎ่ง†่ง‰ๆจกๅž‹""" + # TODO: ้›†ๆˆ SAGE ็ปŸไธ€ๆŽจ็†ๆœๅŠกๆˆ–ๅ…ถไป–่ง†่ง‰ๆจกๅž‹ + # Issue URL: https://github.com/intellistream/SAGE/issues/899 + print(f" Loading vision model: {self.config['models']['vision_model']}") + + # ่ฟ™้‡Œๅฏไปฅ้›†ๆˆ: + # 1. Qwen2-VL for general vision understanding + # 2. SAM (Segment Anything Model) for segmentation + # 3. Medical imaging specific models + + self.vision_model = "placeholder" # ๅฎž้™…ๅบ”่ฏฅๅŠ ่ฝฝๆจกๅž‹ + + image_processing_cfg = self.config.get("image_processing", {}) + + # Avoid heavy downloads in tests unless explicitly enabled + test_mode = os.getenv("SAGE_TEST_MODE", "").lower() in {"1", "true", "yes", "on"} + skip_heavy_models = bool(self.config.get("skip_model_download", test_mode)) + + # ่ฎพ็ฝฎ็‰นๅพๆๅ–ๆจกๅž‹ + self.feature_model = None + self.feature_processor = None + self.feature_method = image_processing_cfg.get("feature_extraction", {}).get( + "method", "clip" + ) + + if skip_heavy_models: + print(" Skipping feature extractor setup (test/skip_model_download)") + elif TORCH_AVAILABLE: + try: + if self.feature_method == "clip": + self._setup_clip_model() + elif self.feature_method == "dinov2": + self._setup_dinov2_model() + else: + print( + f" Warning: Unknown feature method '{self.feature_method}', using mock features" + ) + except Exception as e: + print(f" Warning: Failed to load feature extraction model: {e}") + print(" Falling back to mock features") + else: + print(" Warning: PyTorch not available, feature extraction will use mock features") + + # ่ฎพ็ฝฎๅˆ†ๅ‰ฒๅ‚ๆ•ฐ + self.segmentation_config = image_processing_cfg.get("segmentation", {}) or {} + self.segmentation_enabled = ( + bool(self.segmentation_config.get("enabled", False)) and not skip_heavy_models + ) + disc_levels_cfg = self.segmentation_config.get("disc_levels") + self.disc_levels = list(disc_levels_cfg) if disc_levels_cfg else DEFAULT_DISC_LEVELS.copy() + self.pixel_spacing_mm = float( + self.segmentation_config.get("pixel_spacing_mm", self.pixel_spacing_mm) + ) + self.baseline_disc_height_mm = float( + self.segmentation_config.get( + "baseline_disc_height_mm", + self.segmentation_config.get("expected_height_mm", self.baseline_disc_height_mm), + ) + ) + self.segmentation_thresholds = { + "min_height_ratio": 0.02, + "max_height_ratio": 0.18, + "min_aspect_ratio": 1.5, + "min_width_ratio": 0.12, + "min_vertical_pos": 0.08, + "max_vertical_pos": 0.95, + "min_confidence": 0.35, + "min_separation_ratio": 0.05, + } + thresholds_cfg = self.segmentation_config.get("thresholds", {}) or {} + for key, value in thresholds_cfg.items(): + try: + self.segmentation_thresholds[key] = float(value) + except (TypeError, ValueError): + continue + + if self.segmentation_enabled: + self._setup_segmentation_model() + + def _setup_clip_model(self): + """่ฎพ็ฝฎ CLIP ๆจกๅž‹็”จไบŽ็‰นๅพๆๅ–""" + try: + model_name = "openai/clip-vit-base-patch32" + print(f" Loading CLIP model: {model_name}") + self.feature_processor = CLIPProcessor.from_pretrained(model_name) + self.feature_model = CLIPModel.from_pretrained(model_name) + + # ็งปๅŠจๅˆฐ GPU (ๅฆ‚ๆžœๅฏ็”จ) + if torch.cuda.is_available(): + self.feature_model = self.feature_model.cuda() + print(" CLIP model loaded on GPU") + else: + print(" CLIP model loaded on CPU") + + self.feature_model.eval() # ่ฎพ็ฝฎไธบ่ฏ„ไผฐๆจกๅผ + except Exception as e: + print(f" Failed to load CLIP model: {e}") + raise + + def _setup_dinov2_model(self): + """่ฎพ็ฝฎ DINOv2 ๆจกๅž‹็”จไบŽ็‰นๅพๆๅ–""" + try: + model_name = "facebook/dinov2-base" + print(f" Loading DINOv2 model: {model_name}") + self.feature_processor = AutoImageProcessor.from_pretrained(model_name) + self.feature_model = AutoModel.from_pretrained(model_name) + + # ็งปๅŠจๅˆฐ GPU (ๅฆ‚ๆžœๅฏ็”จ) + if torch.cuda.is_available(): + self.feature_model = self.feature_model.cuda() + print(" DINOv2 model loaded on GPU") + else: + print(" DINOv2 model loaded on CPU") + + self.feature_model.eval() # ่ฎพ็ฝฎไธบ่ฏ„ไผฐๆจกๅผ + except Exception as e: + print(f" Failed to load DINOv2 model: {e}") + raise + + def _setup_segmentation_model(self): + """่ฎพ็ฝฎๆคŽ้—ด็›˜ๅˆ†ๅ‰ฒๆจกๅž‹""" + if not TORCH_AVAILABLE: + print(" Warning: Segmentation requires PyTorch, falling back to heuristics") + return + + model_name = (self.segmentation_config.get("model") or "sam").lower() + try: + if model_name.startswith("sam") or model_name.startswith("medsam"): + self.segmentation_model = self._setup_sam_segmenter(model_name) + self.segmenter_type = "sam" + else: + print( + f" Warning: Unsupported segmentation model '{model_name}', using heuristics" + ) + self.segmentation_model = None + except Exception as exc: + print(f" Warning: Failed to initialize segmentation model '{model_name}': {exc}") + self.segmentation_model = None + self.segmenter_type = None + + def _setup_sam_segmenter(self, model_name: str): + """ๅˆๅง‹ๅŒ– Segment Anything ๆจกๅž‹""" + try: + from segment_anything import SamAutomaticMaskGenerator, sam_model_registry + except ImportError as exc: # pragma: no cover - ๅฏ้€‰ไพ่ต– + raise RuntimeError( + "segment_anything is not installed. Please install it to use SAM segmentation" + ) from exc + + checkpoint_path = self.segmentation_config.get("checkpoint_path") + if not checkpoint_path: + raise ValueError("Segmentation config missing 'checkpoint_path'") + + checkpoint = Path(checkpoint_path).expanduser() + if not checkpoint.exists(): + raise FileNotFoundError(f"Segmentation checkpoint not found: {checkpoint}") + + variant = self.segmentation_config.get("sam_variant") + variant = variant or { + "sam": "vit_h", + "sam-vit-h": "vit_h", + "sam-vit-b": "vit_b", + "sam-vit-l": "vit_l", + "medsam": "vit_b", + }.get(model_name, model_name.replace("sam-", "")) + + if variant not in sam_model_registry: + raise ValueError(f"Unsupported SAM variant '{variant}'") + + print(f" Loading SAM segmentation model ({variant}) from {checkpoint}") + sam = sam_model_registry[variant](checkpoint=str(checkpoint)) + self.segmenter_device = "cuda" if torch.cuda.is_available() else "cpu" + sam.to(device=self.segmenter_device) + + generator_kwargs = { + "points_per_side": self.segmentation_config.get("points_per_side", 16), + "pred_iou_thresh": self.segmentation_config.get("pred_iou_thresh", 0.86), + "stability_score_thresh": self.segmentation_config.get("stability_score_thresh", 0.92), + "crop_n_layers": self.segmentation_config.get("crop_n_layers", 1), + "crop_n_points_downscale_factor": self.segmentation_config.get( + "crop_n_points_downscale_factor", 2 + ), + "min_mask_region_area": self.segmentation_config.get("min_mask_region_area", 200), + } + + print( + " SAM segmentation ready " + f"(points_per_side={generator_kwargs['points_per_side']}, device={self.segmenter_device})" + ) + return SamAutomaticMaskGenerator(sam, **generator_kwargs) + + def analyze(self, image_path: str) -> dict[str, Any]: + """ + ๅˆ†ๆžMRIๅฝฑๅƒ + + Args: + image_path: ๅฝฑๅƒๆ–‡ไปถ่ทฏๅพ„ + + Returns: + ๅฝฑๅƒ็‰นๅพๅญ—ๅ…ธ + """ + image_path_obj = Path(image_path) + + if not image_path_obj.exists(): + # ๅˆ›ๅปบๆจกๆ‹Ÿๆ•ฐๆฎ็”จไบŽๆผ”็คบ + return self._create_mock_analysis() + + # Step 1: ๅŠ ่ฝฝๅ’Œ้ข„ๅค„็†ๅ›พๅƒ + image = self._load_image(image_path_obj) + + # Step 2: ่ดจ้‡่ฏ„ไผฐ + quality_score = self._assess_quality(image) + + # Step 3: ่งฃๅ‰–็ป“ๆž„ๅˆ†ๅ‰ฒ + vertebrae = self._segment_vertebrae(image) + discs = self._segment_discs(image) + + # Step 4: ็—…ๅ˜ๆฃ€ๆต‹ + abnormalities = self._detect_abnormalities(image, vertebrae, discs) + + # Step 5: ็‰นๅพๆๅ– + image_embedding = self._extract_features(image) + + return { + "vertebrae": vertebrae, + "discs": discs, + "abnormalities": abnormalities, + "image_quality": quality_score, + "image_embedding": image_embedding, + "image_path": str(image_path), + } + + def _load_image(self, image_path: Path): + """ + ๅŠ ่ฝฝๅฝฑๅƒ + + ๆ”ฏๆŒๆ ผๅผ: + - DICOMๆ ผๅผ (.dcm, .dicom) + - ๅธธ่ง„ๅ›พๅƒๆ ผๅผ (PNG, JPG, JPEG, BMP, TIFF็ญ‰) + + Args: + image_path: ๅฝฑๅƒๆ–‡ไปถ่ทฏๅพ„ + + Returns: + numpyๆ•ฐ็ป„ๆ ผๅผ็š„็ฐๅบฆๅ›พๅƒ๏ผŒๅคฑ่ดฅๆ—ถ่ฟ”ๅ›žNone + """ + if not image_path.exists(): + print(f" Warning: ๅ›พๅƒๆ–‡ไปถไธๅญ˜ๅœจ {image_path}") + return None + + # ่Žทๅ–ๆ–‡ไปถๆ‰ฉๅฑ•ๅ + file_extension = image_path.suffix.lower() + + try: + # ๅฐ่ฏ•ๅŠ ่ฝฝDICOMๆ ผๅผ + if file_extension in [".dcm", ".dicom"]: + return self._load_dicom_image(image_path) + + # ๅฐ่ฏ•ๅŠ ่ฝฝๅธธ่ง„ๅ›พๅƒๆ ผๅผ + return self._load_regular_image(image_path) + + except Exception as e: + print(f" Warning: ๆ— ๆณ•ๅŠ ่ฝฝๅ›พๅƒ {image_path}: {e}") + return None + + def _load_dicom_image(self, image_path: Path): + """ + ๅŠ ่ฝฝDICOMๆ ผๅผๅฝฑๅƒ + + Args: + image_path: DICOMๆ–‡ไปถ่ทฏๅพ„ + + Returns: + numpyๆ•ฐ็ป„ๆ ผๅผ็š„็ฐๅบฆๅ›พๅƒ + """ + try: + import pydicom + + # ่ฏปๅ–DICOMๆ–‡ไปถ + dicom_data = pydicom.dcmread(str(image_path)) + + # ่Žทๅ–ๅƒ็ด ๆ•ฐๆฎ + image_array = dicom_data.pixel_array + + # ๅบ”็”จDICOM็š„็ช—ๅฎฝ็ช—ไฝ๏ผˆๅฆ‚ๆžœๅญ˜ๅœจ๏ผ‰ + if hasattr(dicom_data, "WindowCenter") and hasattr(dicom_data, "WindowWidth"): + window_center = ( + dicom_data.WindowCenter + if isinstance(dicom_data.WindowCenter, (int, float)) + else dicom_data.WindowCenter[0] + ) + window_width = ( + dicom_data.WindowWidth + if isinstance(dicom_data.WindowWidth, (int, float)) + else dicom_data.WindowWidth[0] + ) + + # ๅบ”็”จ็ช—ๅฎฝ็ช—ไฝ + img_min = window_center - window_width / 2 + img_max = window_center + window_width / 2 + image_array = np.clip(image_array, img_min, img_max) + + # ๅฝ’ไธ€ๅŒ–ๅˆฐ0-255่Œƒๅ›ด + if image_array.max() > image_array.min(): + image_array = ( + (image_array - image_array.min()) + / (image_array.max() - image_array.min()) + * 255 + ) + else: + image_array = np.zeros_like(image_array) + + # ่ฝฌๆขไธบuint8็ฑปๅž‹ + image_array = image_array.astype(np.uint8) + + return image_array + + except ImportError: + print(" Warning: pydicomๆœชๅฎ‰่ฃ…๏ผŒๆ— ๆณ•ๅŠ ่ฝฝDICOMๆ–‡ไปถ") + print(" ่ฏท่ฟ่กŒ: pip install pydicom") + return None + except Exception as e: + print(f" Warning: ๅŠ ่ฝฝDICOMๆ–‡ไปถๅคฑ่ดฅ {image_path}: {e}") + return None + + def _load_regular_image(self, image_path: Path): + """ + ๅŠ ่ฝฝๅธธ่ง„ๅ›พๅƒๆ ผๅผ + + Args: + image_path: ๅ›พๅƒๆ–‡ไปถ่ทฏๅพ„ + + Returns: + numpyๆ•ฐ็ป„ๆ ผๅผ็š„็ฐๅบฆๅ›พๅƒ + """ + try: + from PIL import Image + + # ไฝฟ็”จPILๅŠ ่ฝฝๅ›พๅƒๅนถ่ฝฌๆขไธบ็ฐๅบฆ + image = Image.open(image_path) + + # ่ฝฌๆขไธบ็ฐๅบฆๅ›พๅƒ + if image.mode != "L": + image = image.convert("L") + + # ่ฝฌๆขไธบnumpyๆ•ฐ็ป„ + return np.array(image) + + except Exception as e: + print(f" Warning: ๅŠ ่ฝฝๅ›พๅƒๆ–‡ไปถๅคฑ่ดฅ {image_path}: {e}") + return None + + def _assess_quality(self, image) -> float: + """ + ่ฏ„ไผฐๅฝฑๅƒ่ดจ้‡ + + ไฝฟ็”จๅคšไธชๆŒ‡ๆ ‡็ปผๅˆ่ฏ„ไผฐ: + 1. ๅฏนๆฏ”ๅบฆ (Contrast): ๆ ‡ๅ‡†ๅทฎใ€ๅŠจๆ€่Œƒๅ›ดไธŽๅˆ†ไฝๅŒบ้—ด + 2. ๆธ…ๆ™ฐๅบฆ (Sharpness): ๆ‹‰ๆ™ฎๆ‹‰ๆ–ฏๆ–นๅทฎ + ๆขฏๅบฆ่ƒฝ้‡ + 3. ๅ™ชๅฃฐๆฐดๅนณ (Noise): ้ซ˜้ข‘ๆฎ‹ๅทฎ่ƒฝ้‡ + 4. ไบฎๅบฆๅˆ†ๅธƒ (Brightness): ็›ดๆ–นๅ›พๅ ็”จไธŽไบฎๅบฆๅนณ่กก + + Returns: + 0-1ไน‹้—ด็š„่ดจ้‡ๅˆ†ๆ•ฐ๏ผŒ่ถŠ้ซ˜่กจ็คบ่ดจ้‡่ถŠๅฅฝ + """ + if image is None: + return 0.0 + + try: + img = self._prepare_quality_image(image) + if img.size == 0: + return 0.0 + + metrics = self._compute_quality_metrics(img) + return self._combine_quality_scores(metrics) + + except Exception as e: # pragma: no cover - ้˜ฒๅพกๆ€งๅ›ž้€€ + print(f" Warning: ่ดจ้‡่ฏ„ไผฐๅคฑ่ดฅ: {e}") + return 0.5 + + def _prepare_quality_image(self, image: Any) -> np.ndarray: + """ๅฐ†่พ“ๅ…ฅๅ›พๅƒ่ฝฌๆขไธบ[0,1]่Œƒๅ›ด็š„็ฐๅบฆๅ›พ""" + arr = np.asarray(image) + if arr.ndim == 3: + arr = arr.mean(axis=-1) + arr = arr.astype(np.float32, copy=False) + if arr.size == 0: + return arr + + max_val = float(arr.max()) + min_val = float(arr.min()) + if max_val == min_val == 0.0: + return arr + + if max_val > 1.0 or min_val < 0.0: + # ไพๆฎไฝๆทฑๅบฆไผฐ็ฎ—ๅฝ’ไธ€ๅŒ–็ณปๆ•ฐ + if max_val <= 255.0: + scale = 255.0 + elif max_val <= 1023.0: + scale = 1023.0 + elif max_val <= 4095.0: + scale = 4095.0 + else: + scale = max_val + arr = arr / max(scale, 1e-6) + if arr.min() < 0: + arr = arr - arr.min() + arr = arr / max(arr.max(), 1e-6) + + return np.clip(arr, 0.0, 1.0) + + def _compute_quality_metrics(self, img: np.ndarray) -> dict[str, float]: + """ๅˆ†ๅˆซ่ฎก็ฎ—ๅฏนๆฏ”ๅบฆใ€ๆธ…ๆ™ฐๅบฆใ€ๅ™ชๅฃฐไธŽไบฎๅบฆๅˆ†ๆ•ฐ""" + metrics = { + "contrast": self._evaluate_contrast(img), + "sharpness": self._evaluate_sharpness(img), + "noise": self._evaluate_noise(img), + "brightness": self._evaluate_brightness(img), + } + return {k: float(np.clip(v, 0.0, 1.0)) for k, v in metrics.items()} + + def _evaluate_contrast(self, img: np.ndarray) -> float: + std_dev = float(np.std(img)) + dynamic_range = float(np.ptp(img)) + percentile_low, percentile_high = np.percentile(img, (5, 95)) + percentile_range = float(max(percentile_high - percentile_low, 0.0)) + + std_component = min(std_dev / 0.18, 1.0) + range_component = min(dynamic_range / 0.85, 1.0) + percentile_component = min(percentile_range / 0.75, 1.0) + + return float( + np.clip( + 0.5 * std_component + 0.3 * range_component + 0.2 * percentile_component, + 0.0, + 1.0, + ) + ) + + def _evaluate_sharpness(self, img: np.ndarray) -> float: + if img.size == 0: + return 0.0 + + lap_response = self._apply_kernel(img, self._LAPLACIAN_KERNEL) + lap_var = float(np.mean(lap_response**2)) + lap_component = min(lap_var / 0.012, 1.0) + + grad_x = self._apply_kernel(img, self._SOBEL_X) + grad_y = self._apply_kernel(img, self._SOBEL_Y) + gradient_energy = float(np.mean(np.hypot(grad_x, grad_y))) + gradient_component = min(gradient_energy / 0.35, 1.0) + + return float(np.clip(0.6 * lap_component + 0.4 * gradient_component, 0.0, 1.0)) + + def _evaluate_noise(self, img: np.ndarray) -> float: + if min(img.shape) < 2: + return 0.8 + + smoothed = self._apply_kernel(img, self._BLUR_KERNEL) + high_freq = img - smoothed + grad_x = self._apply_kernel(img, self._SOBEL_X) + grad_y = self._apply_kernel(img, self._SOBEL_Y) + grad_mag = np.hypot(grad_x, grad_y) + + hf_energy = float(np.mean(high_freq**2)) + smooth_mask = grad_mag < 0.05 + if np.any(smooth_mask): + smooth_noise = float(np.mean(np.abs(high_freq[smooth_mask]))) + else: + smooth_noise = float(np.mean(np.abs(high_freq))) + + energy_component = max(1.0 - hf_energy / 0.015, 0.0) + smooth_component = max(1.0 - smooth_noise / 0.06, 0.0) + + return float(np.clip(0.5 * energy_component + 0.5 * smooth_component, 0.0, 1.0)) + + def _evaluate_brightness(self, img: np.ndarray) -> float: + if img.size == 0: + return 0.0 + + mean_intensity = float(np.mean(img)) + histogram, _ = np.histogram(img, bins=32, range=(0.0, 1.0)) + if histogram.sum() == 0: + return 0.0 + + hist = histogram.astype(np.float32) / float(histogram.sum()) + clipping_ratio = float(hist[0] + hist[-1]) + + mid_balance = max(1.0 - abs(mean_intensity - 0.5) * 2.0, 0.0) + distribution_score = max(1.0 - clipping_ratio * 0.5, 0.0) + + brightness_score = mid_balance * 0.7 + distribution_score * 0.3 + return float(np.clip(brightness_score, 0.0, 1.0)) + + def _combine_quality_scores(self, metrics: dict[str, float]) -> float: + weights_cfg = ( + self.config.get("image_processing", {}).get("quality_weights", {}) + if isinstance(self.config, dict) + else {} + ) + weights: dict[str, float] = {} + total = 0.0 + for key, default_value in self._DEFAULT_QUALITY_WEIGHTS.items(): + weight = float(weights_cfg.get(key, default_value)) + if weight < 0: + weight = 0.0 + weights[key] = weight + total += weight + + if total <= 0: + weights = self._DEFAULT_QUALITY_WEIGHTS.copy() + total = sum(weights.values()) + + for key in weights: + weights[key] /= total + + score = 0.0 + for key, weight in weights.items(): + score += float(metrics.get(key, 0.0)) * weight + + noise_metric = float(np.clip(metrics.get("noise", 1.0), 0.0, 1.0)) + dampening = 0.55 + 0.45 * noise_metric # ้ซ˜ๅ™ชๅฃฐๆ—ถ้ขๅค–ๆƒฉ็ฝšๆ€ปไฝ“ๅˆ†ๆ•ฐ + return float(np.clip(score * dampening, 0.0, 1.0)) + + def _apply_kernel(self, img: np.ndarray, kernel: np.ndarray) -> np.ndarray: + """ไฝฟ็”จๆป‘ๅŠจ็ช—ๅฃๅท็งฏๆ ธ๏ผŒๅฟ…่ฆๆ—ถๅ›ž้€€่‡ณๅพช็Žฏ่ฎก็ฎ—""" + pad_y = kernel.shape[0] // 2 + pad_x = kernel.shape[1] // 2 + padded = np.pad(img, ((pad_y, pad_y), (pad_x, pad_x)), mode="reflect") + + if sliding_window_view is not None: + windows = sliding_window_view(padded, kernel.shape) + response = np.tensordot(windows, kernel, axes=((2, 3), (0, 1))) + return response.astype(np.float32, copy=False) + + return self._apply_kernel_fallback(padded, kernel) + + @staticmethod + def _apply_kernel_fallback(padded: np.ndarray, kernel: np.ndarray) -> np.ndarray: + """ๅœจๆ—ง็‰ˆNumPyไธŠไฝฟ็”จ้€ๅƒ็ด ๅท็งฏ๏ผŒไฟ่ฏ็ฎ—ๆณ•ๅฏ็”จ""" + k_h, k_w = kernel.shape + out_h = padded.shape[0] - k_h + 1 + out_w = padded.shape[1] - k_w + 1 + output = np.empty((out_h, out_w), dtype=np.float32) + for i in range(out_h): + for j in range(out_w): + patch = padded[i : i + k_h, j : j + k_w] + output[i, j] = float(np.sum(patch * kernel)) + return output + + def _segment_vertebrae(self, image) -> list[dict[str, Any]]: + """ๅˆ†ๅ‰ฒๆคŽไฝ“""" + vertebrae_names = ["L1", "L2", "L3", "L4", "L5"] + + if image is None: + return [ + { + "name": name, + "position": {"x": 100, "y": 50 + i * 80}, + "size": {"width": 40, "height": 30}, + "features": { + "signal_intensity": 0.7 + i * 0.02, + "shape_regularity": 0.9, + }, + } + for i, name in enumerate(vertebrae_names) + ] + + array = np.asarray(image) + if array.ndim == 3: + array = array.mean(axis=2) + + height, width = array.shape[:2] + if height < 10 or width < 10: + return [ + { + "name": name, + "position": {"x": 100, "y": 50 + i * 80}, + "size": {"width": 40, "height": 30}, + "features": { + "signal_intensity": 0.7 + i * 0.02, + "shape_regularity": 0.9, + }, + } + for i, name in enumerate(vertebrae_names) + ] + + gray = array.astype(np.float32) + min_val = float(gray.min()) + max_val = float(gray.max()) + if max_val - min_val > 1e-6: + gray = (gray - min_val) / (max_val - min_val) + else: + gray = np.zeros_like(gray) + + profile = gray.mean(axis=1) + window = max(3, int(height * 0.03)) + kernel = np.ones(window, dtype=np.float32) / float(window) + smoothed = np.convolve(profile, kernel, mode="same") + + vertebrae: list[dict[str, Any]] = [] + box_width = max(20, int(width * 0.35)) + box_height = max(14, int(height * 0.08)) + left = max(0, (width - box_width) // 2) + + band = max(1, height // len(vertebrae_names)) + for index, name in enumerate(vertebrae_names): + start = index * band + end = height if index == len(vertebrae_names) - 1 else min(height, (index + 1) * band) + if end <= start: + center_y = min(height - 1, start) + else: + local = smoothed[start:end] + center_y = start + int(np.argmax(local)) + + top = max(0, center_y - box_height // 2) + bottom = min(height, top + box_height) + top = max(0, bottom - box_height) + + region = gray[top:bottom, left : left + box_width] + if region.size == 0: + signal_intensity = 0.0 + shape_regularity = 0.75 + else: + signal_intensity = float(np.clip(region.mean(), 0.0, 1.0)) + gradient = ( + np.abs(np.diff(region.mean(axis=1))) if region.shape[0] > 1 else np.array([0]) + ) + roughness = float(gradient.mean()) if gradient.size > 0 else 0.0 + shape_regularity = float(np.clip(1.0 - roughness, 0.6, 0.98)) + + vertebrae.append( + { + "name": name, + "position": {"x": int(left), "y": int(top)}, + "size": {"width": int(box_width), "height": int(bottom - top)}, + "features": { + "signal_intensity": signal_intensity, + "shape_regularity": shape_regularity, + }, + } + ) + + return vertebrae + + def _segment_discs(self, image) -> list[dict[str, Any]]: + """ๅˆ†ๅ‰ฒๆคŽ้—ด็›˜""" + if image is None: + return self._generate_heuristic_discs(image) + + if self.segmentation_enabled and self.segmentation_model is not None: + try: + rgb_image = self._prepare_segmentation_input(image) + mask_outputs = self._run_segmentation_model(rgb_image) + discs = self._build_discs_from_masks(mask_outputs, image) + if discs: + return discs + print( + " Warning: Segmentation produced no disc candidates, using heuristic fallback" + ) + except Exception as exc: + print(f" Warning: Disc segmentation failed ({exc}), using heuristic fallback") + + return self._generate_heuristic_discs(image) + + def _prepare_segmentation_input(self, image: np.ndarray) -> np.ndarray: + """ๅฐ†็ฐๅบฆๅ›พ่ฝฌๆขไธบๅˆ†ๅ‰ฒๆจกๅž‹ๅฏ็”จ็š„ RGB ๅ›พ""" + array = np.array(image) + + if array.dtype != np.uint8: + min_val = float(array.min()) if array.size else 0.0 + max_val = float(array.max()) if array.size else 0.0 + if max_val - min_val > 1e-5: + array = ((array - min_val) / (max_val - min_val) * 255).astype(np.uint8) + else: + array = np.zeros_like(array, dtype=np.uint8) + + if array.ndim == 2: + array = np.stack([array] * 3, axis=-1) + elif array.ndim == 3 and array.shape[2] == 1: + array = np.repeat(array, 3, axis=2) + + return array + + def _run_segmentation_model(self, rgb_image: np.ndarray) -> list[dict[str, Any]]: + """่ฟ่กŒๅˆ†ๅ‰ฒๆจกๅž‹""" + if hasattr(self.segmentation_model, "generate"): + return self.segmentation_model.generate(rgb_image) + if callable(self.segmentation_model): + return self.segmentation_model(rgb_image) + raise RuntimeError("Segmentation model does not expose a generate() method") + + def _build_discs_from_masks( + self, masks: list[dict[str, Any]], original_image: np.ndarray + ) -> list[dict[str, Any]]: + """ๆ นๆฎๅˆ†ๅ‰ฒๆŽฉ็ ๆž„ๅปบๆคŽ้—ด็›˜ไฟกๆฏ""" + if not masks: + return [] + + height, width = original_image.shape[:2] + candidates = self._select_disc_masks(masks, (height, width)) + if not candidates: + return [] + + candidates.sort(key=lambda item: item["center"][1]) + discs: list[dict[str, Any]] = [] + + for idx, candidate in enumerate(candidates): + if idx >= len(self.disc_levels): + break + features, position = self._compute_disc_features(original_image, candidate) + discs.append( + { + "level": self.disc_levels[idx], + "position": position, + "features": features, + } + ) + + return discs + + def _select_disc_masks( + self, masks: list[dict[str, Any]], image_shape: tuple[int, int] + ) -> list[dict[str, Any]]: + """็ญ›้€‰ๆคŽ้—ด็›˜ๅ€™้€‰ๆŽฉ็ """ + height, width = image_shape + filtered: list[dict[str, Any]] = [] + + for mask in masks: + segmentation = mask.get("segmentation") + if segmentation is None: + continue + + segmentation = np.asarray(segmentation, dtype=bool) + + bbox = mask.get("bbox") + if bbox is None: + bbox = self._mask_to_bbox(segmentation) + if bbox is None: + continue + + x, y, w, h = [int(value) for value in bbox] + if w <= 0 or h <= 0: + continue + + height_ratio = h / max(height, 1) + width_ratio = w / max(width, 1) + aspect_ratio = w / (h + 1e-5) + center = (x + w / 2.0, y + h / 2.0) + vertical_pos = center[1] / max(height, 1) + confidence = float(mask.get("predicted_iou", mask.get("stability_score", 0.0))) + stability = float(mask.get("stability_score", confidence)) + + if height_ratio < self.segmentation_thresholds["min_height_ratio"]: + continue + if height_ratio > self.segmentation_thresholds["max_height_ratio"]: + continue + if width_ratio < self.segmentation_thresholds["min_width_ratio"]: + continue + if aspect_ratio < self.segmentation_thresholds["min_aspect_ratio"]: + continue + if not ( + self.segmentation_thresholds["min_vertical_pos"] + <= vertical_pos + <= self.segmentation_thresholds["max_vertical_pos"] + ): + continue + if confidence < self.segmentation_thresholds["min_confidence"]: + continue + + filtered.append( + { + "segmentation": segmentation, + "bbox": (x, y, w, h), + "center": center, + "confidence": confidence, + "stability": stability, + "area": float(mask.get("area", float(segmentation.sum()))), + } + ) + + if not filtered: + return [] + + # ็งป้™ค่ฟ‡ไบŽๆŽฅ่ฟ‘็š„ๅ€™้€‰๏ผŒไฟ่ฏๆฏไธชๆคŽ้—ด็›˜ๅชไฟ็•™ไธ€ไธชๆŽฉ็  + min_sep = self.segmentation_thresholds.get("min_separation_ratio", 0.05) * height + deduped: list[dict[str, Any]] = [] + filtered.sort(key=lambda item: (-item["confidence"], item["center"][1])) + for candidate in filtered: + if any( + abs(candidate["center"][1] - existing["center"][1]) < min_sep + for existing in deduped + ): + continue + deduped.append(candidate) + + deduped.sort(key=lambda item: item["center"][1]) + return deduped + + def _mask_to_bbox(self, mask: np.ndarray) -> tuple[int, int, int, int] | None: + """ๆ นๆฎๆŽฉ็ ่ฎก็ฎ—ๅŒ…ๅ›ด็›’""" + if mask.ndim != 2 or not mask.any(): + return None + + rows = np.any(mask, axis=1) + cols = np.any(mask, axis=0) + y_indices = np.where(rows)[0] + x_indices = np.where(cols)[0] + if y_indices.size == 0 or x_indices.size == 0: + return None + + y_min, y_max = int(y_indices[0]), int(y_indices[-1]) + x_min, x_max = int(x_indices[0]), int(x_indices[-1]) + return x_min, y_min, x_max - x_min + 1, y_max - y_min + 1 + + def _compute_disc_features( + self, image: np.ndarray, mask_info: dict[str, Any] + ) -> tuple[dict[str, Any], dict[str, int]]: + """ไปŽๆŽฉ็ ไธญๆๅ–ๆคŽ้—ด็›˜็‰นๅพ""" + x, y, w, h = mask_info["bbox"] + height, width = image.shape[:2] + crop = image[y : y + h, x : x + w] + segmentation = mask_info["segmentation"][y : y + h, x : x + w] + + if crop.ndim == 3: + crop_gray = crop[:, :, 0] + else: + crop_gray = crop + + if segmentation.any(): + disc_pixels = crop_gray[segmentation] + intensity = float(disc_pixels.mean() / 255.0) if disc_pixels.size else 0.0 + else: + intensity = float(crop_gray.mean() / 255.0) if crop_gray.size else 0.0 + + height_ratio = h / max(height, 1) + height_mm = round(height_ratio * self.pixel_spacing_mm, 2) + degeneration_score = max(0.0, 1.0 - height_mm / max(self.baseline_disc_height_mm, 1e-3)) + irregularity = 1.0 - mask_info["area"] / max(w * h, 1) + lateral_shift = self._compute_lateral_imbalance(segmentation) + + herniation = bool( + irregularity > self.segmentation_config.get("irregularity_threshold", 0.35) + or lateral_shift > self.segmentation_config.get("lateral_shift_threshold", 0.18) + or degeneration_score > 0.35 + ) + + features = { + "height": max(height_mm, 0.1), + "height_ratio": round(height_ratio, 4), + "signal_intensity": round(max(min(intensity, 1.0), 0.0), 3), + "degeneration_score": round(degeneration_score, 3), + "irregularity": round(max(irregularity, 0.0), 3), + "lateral_shift": round(lateral_shift, 3), + "herniation": herniation, + "mask_confidence": round(mask_info.get("confidence", 0.0), 3), + "stability": round(mask_info.get("stability", 0.0), 3), + } + + position = {"x": int(x + w / 2), "y": int(y + h / 2)} + return features, position + + def _compute_lateral_imbalance(self, mask: np.ndarray) -> float: + """่ฎก็ฎ—ๅทฆๅณไพง้ข็งฏๅทฎๅผ‚๏ผŒ็”จไบŽๅˆคๆ–ญๆคŽ้—ด็›˜ๅ็งป""" + if mask.size == 0: + return 0.0 + + column_profile = mask.sum(axis=0).astype(np.float32) + mid = column_profile.size // 2 + left = column_profile[:mid].sum() + right = column_profile[mid:].sum() + total = left + right + if total == 0: + return 0.0 + return float(abs(left - right) / total) + + def _generate_heuristic_discs(self, image: np.ndarray | None) -> list[dict[str, Any]]: + """ๅŸบไบŽๅ‡ ไฝ•ๅฏๅ‘ๅผ็”ŸๆˆๆคŽ้—ด็›˜ไฟกๆฏ๏ผˆๅœจ็ผบๅฐ‘ๆจกๅž‹ๆ—ถไฝฟ็”จ๏ผ‰""" + height, width = image.shape[:2] if isinstance(image, np.ndarray) else (512, 512) + discs: list[dict[str, Any]] = [] + + for idx, level in enumerate(self.disc_levels): + y = int((0.2 + idx * 0.12) * height) + x = width // 2 + window = None + if isinstance(image, np.ndarray): + y0 = max(y - 4, 0) + y1 = min(y + 4, height) + window = image[y0:y1, :] + + if window is not None and window.size: + intensity = float(window.mean() / 255.0) + else: + intensity = max(0.0, 0.75 - idx * 0.08) + + height_value = max(self.baseline_disc_height_mm - idx * 0.45, 4.0) + discs.append( + { + "level": level, + "position": {"x": x, "y": y}, + "features": { + "height": round(height_value, 2), + "height_ratio": round( + height_value / max(self.baseline_disc_height_mm, 1e-3), 3 + ), + "signal_intensity": round(max(min(intensity, 1.0), 0.0), 3), + "herniation": idx >= 3, + "degeneration_score": round(idx * 0.1, 3), + "irregularity": 0.2 + idx * 0.05, + "lateral_shift": 0.05 * idx, + "mask_confidence": 0.0, + "stability": 0.0, + }, + } + ) + + return discs + + def _detect_abnormalities( + self, image, vertebrae: list[dict], discs: list[dict] + ) -> list[dict[str, Any]]: + """ๆฃ€ๆต‹ๅผ‚ๅธธ""" + abnormalities = [] + + # ๆฃ€ๆŸฅๆคŽ้—ด็›˜็ชๅ‡บ + for disc in discs: + if disc["features"].get("herniation"): + abnormalities.append( + { + "type": "disc_herniation", + "location": disc["level"], + "severity": "moderate", + "description": f"{disc['level']} ๆคŽ้—ด็›˜็ชๅ‡บ", + } + ) + + # ๆฃ€ๆŸฅๆคŽ้—ด็›˜้€€ๅ˜ + for disc in discs: + if disc["features"]["height"] < 6.0: + abnormalities.append( + { + "type": "disc_degeneration", + "location": disc["level"], + "severity": ("mild" if disc["features"]["height"] > 5.0 else "moderate"), + "description": f"{disc['level']} ๆคŽ้—ด็›˜้€€่กŒๆ€งๅ˜", + } + ) + + # ๆฃ€ๆŸฅไฟกๅทๅผบๅบฆๅผ‚ๅธธ + for disc in discs: + if disc["features"]["signal_intensity"] < 0.6: + abnormalities.append( + { + "type": "signal_change", + "location": disc["level"], + "severity": "mild", + "description": f"{disc['level']} ๆคŽ้—ด็›˜ไฟกๅทๅ‡ไฝŽ", + } + ) + + return abnormalities + + def _extract_features(self, image) -> np.ndarray | None: + """ๆๅ–ๅฝฑๅƒ็‰นๅพๅ‘้‡ + + ไฝฟ็”จ้ข„่ฎญ็ปƒๆจกๅž‹ (CLIP/DINOv2) ๆๅ–ๅฝฑๅƒ็‰นๅพ + ๅฆ‚ๆžœๆจกๅž‹ไธๅฏ็”จ๏ผŒๅ›ž้€€ๅˆฐๆจกๆ‹Ÿ็‰นๅพ + + Args: + image: ่พ“ๅ…ฅๅ›พๅƒ (numpy array ๆˆ– PIL Image) + + Returns: + ็‰นๅพๅ‘้‡ (numpy array) ๆˆ– None + """ + if image is None: + return None + + # ๅฆ‚ๆžœๆฒกๆœ‰ๅŠ ่ฝฝ็‰นๅพๆๅ–ๆจกๅž‹๏ผŒไฝฟ็”จๆจกๆ‹Ÿ็‰นๅพ + if self.feature_model is None or not TORCH_AVAILABLE: + print(" Using mock features (model not available)") + return self._create_mock_features() + + try: + # ่ฝฌๆขๅ›พๅƒไธบ PIL Image (ๅฆ‚ๆžœๆ˜ฏ numpy array) + if isinstance(image, np.ndarray): + # ็กฎไฟๆ˜ฏ uint8 ็ฑปๅž‹ + if image.dtype != np.uint8: + image = ((image - image.min()) / (image.max() - image.min()) * 255).astype( + np.uint8 + ) + # ่ฝฌๆข็ฐๅบฆๅ›พไธบ RGB + if len(image.shape) == 2: + image = np.stack([image] * 3, axis=-1) + pil_image = PILImage.fromarray(image) + else: + pil_image = image + + # ไฝฟ็”จๅฏนๅบ”็š„ๆจกๅž‹ๆๅ–็‰นๅพ + if self.feature_method == "clip": + features = self._extract_clip_features(pil_image) + elif self.feature_method == "dinov2": + features = self._extract_dinov2_features(pil_image) + else: + print(f" Unknown feature method: {self.feature_method}") + features = self._create_mock_features() + + return features + + except Exception as e: + print(f" Warning: Feature extraction failed: {e}") + print(" Falling back to mock features") + return self._create_mock_features() + + def _extract_clip_features(self, image: "PILImage.Image") -> np.ndarray: + """ไฝฟ็”จ CLIP ๆๅ–็‰นๅพ""" + with torch.no_grad(): + # ้ข„ๅค„็†ๅ›พๅƒ + inputs = self.feature_processor(images=image, return_tensors="pt") + + # ็งปๅŠจๅˆฐๆญฃ็กฎ็š„่ฎพๅค‡ + if torch.cuda.is_available(): + inputs = {k: v.cuda() for k, v in inputs.items()} + + # ๆๅ–ๅ›พๅƒ็‰นๅพ + image_features = self.feature_model.get_image_features(**inputs) + + # ่ฝฌๆขไธบ numpy array + features = image_features.cpu().numpy().squeeze() + + # ๅฝ’ไธ€ๅŒ– + features = features / np.linalg.norm(features) + + return features.astype(np.float32) + + def _extract_dinov2_features(self, image: "PILImage.Image") -> np.ndarray: + """ไฝฟ็”จ DINOv2 ๆๅ–็‰นๅพ""" + with torch.no_grad(): + # ้ข„ๅค„็†ๅ›พๅƒ + inputs = self.feature_processor(images=image, return_tensors="pt") + + # ็งปๅŠจๅˆฐๆญฃ็กฎ็š„่ฎพๅค‡ + if torch.cuda.is_available(): + inputs = {k: v.cuda() for k, v in inputs.items()} + + # ๆๅ–็‰นๅพ + outputs = self.feature_model(**inputs) + + # ไฝฟ็”จ [CLS] token ็š„่พ“ๅ‡บไฝœไธบๅ›พๅƒ็‰นๅพ + features = outputs.last_hidden_state[:, 0].cpu().numpy().squeeze() + + # ๅฝ’ไธ€ๅŒ– + features = features / np.linalg.norm(features) + + return features.astype(np.float32) + + def _create_mock_features(self) -> np.ndarray: + """ๅˆ›ๅปบๆจกๆ‹Ÿ็‰นๅพๅ‘้‡ (็”จไบŽๆต‹่ฏ•ๆˆ–ๅ›ž้€€)""" + # ่Žทๅ–้…็ฝฎ็š„็‰นๅพ็ปดๅบฆ๏ผŒ้ป˜่ฎค 768 + dimension = ( + self.config.get("image_processing", {}) + .get("feature_extraction", {}) + .get("dimension", 768) + ) + return np.random.randn(dimension).astype(np.float32) + + def _create_mock_analysis(self) -> dict[str, Any]: + """ๅˆ›ๅปบๆจกๆ‹Ÿๅˆ†ๆž็ป“ๆžœ๏ผˆ็”จไบŽๆผ”็คบ๏ผ‰""" + return { + "vertebrae": [ + {"name": f"L{i}", "position": {"x": 100, "y": 50 + i * 80}} for i in range(1, 6) + ], + "discs": self._generate_heuristic_discs(None), + "abnormalities": [ + { + "type": "disc_herniation", + "location": "L4/L5", + "severity": "moderate", + "description": "L4/L5 ๆคŽ้—ด็›˜็ชๅ‡บ", + }, + { + "type": "disc_degeneration", + "location": "L5/S1", + "severity": "mild", + "description": "L5/S1 ๆคŽ้—ด็›˜้€€่กŒๆ€งๅ˜", + }, + ], + "image_quality": 0.85, + "image_embedding": np.random.randn(768).astype(np.float32), + } + + +if __name__ == "__main__": + # ๆต‹่ฏ• + config = {"models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}} + + analyzer = ImageAnalyzer(config) + result = analyzer.analyze("test.jpg") + + print(f"ๆคŽไฝ“ๆ•ฐ้‡: {len(result['vertebrae'])}") + print(f"ๆคŽ้—ด็›˜ๆ•ฐ้‡: {len(result['discs'])}") + print(f"ๅผ‚ๅธธๆ•ฐ้‡: {len(result['abnormalities'])}") diff --git a/apps/src/sage/apps/medical_diagnosis/agents/report_generator.py b/apps/src/sage/apps/medical_diagnosis/agents/report_generator.py new file mode 100644 index 0000000..5592e94 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/agents/report_generator.py @@ -0,0 +1,479 @@ +""" +ๆŠฅๅ‘Š็”ŸๆˆAgent +่ดŸ่ดฃ็”Ÿๆˆ็ป“ๆž„ๅŒ–็š„่ฏŠๆ–ญๆŠฅๅ‘Š +""" + +import contextlib +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +import requests + + +@dataclass +class DiagnosisReport: + """่ฏŠๆ–ญๆŠฅๅ‘Š""" + + diagnoses: list[str] # ่ฏŠๆ–ญๅˆ—่กจ + confidence: float # ็ฝฎไฟกๅบฆ + findings: list[str] # ๅ‘็Žฐๅˆ—่กจ + recommendations: list[str] # ๅปบ่ฎฎๅˆ—่กจ + similar_cases: list[dict[str, Any]] # ็›ธไผผ็—…ไพ‹ + report: str # ๅฎŒๆ•ดๆŠฅๅ‘Š + timestamp: str # ๆ—ถ้—ดๆˆณ + quality_score: float = 0.0 # ่ดจ้‡่ฏ„ๅˆ† + + +class ReportGenerator: + """ + ่ฏŠๆ–ญๆŠฅๅ‘Š็”Ÿๆˆๅ™จ + + ๅŠŸ่ƒฝ: + 1. ๆ•ดๅˆๅฝฑๅƒๅˆ†ๆž็ป“ๆžœ + 2. ๅ‚่€ƒ็›ธไผผ็—…ไพ‹ + 3. ็”Ÿๆˆ็ป“ๆž„ๅŒ–่ฏŠๆ–ญๆŠฅๅ‘Š + 4. ๆไพ›ๆฒป็–—ๅปบ่ฎฎ + """ + + def __init__(self, config: dict): + """ + ๅˆๅง‹ๅŒ–ๆŠฅๅ‘Š็”Ÿๆˆๅ™จ + + Args: + config: ้…็ฝฎๅญ—ๅ…ธ + """ + self.config = config + self.llm_service = None + self._llm_generate_options: dict[str, Any] = {} + self._llm_api_base = "" + self._llm_api_key = "" + self._llm_model = "" + self._setup_llm() + + def _setup_llm(self): + """่ฎพ็ฝฎLLMๆœๅŠก""" + print(f" Loading LLM: {self.config['models']['llm_model']}") + + models_cfg = self.config.get("models", {}) or {} + services_cfg = self.config.get("services", {}) or {} + sagellm_cfg = dict(services_cfg.get("sagellm", {}) or services_cfg.get("llm", {}) or {}) + self._llm_generate_options = self._resolve_generation_options(sagellm_cfg) + + if not sagellm_cfg.get("enabled", True): + print(" Warning: sageLLM service disabled via config, using template reports") + return + + llm_model = models_cfg.get("llm_model") + if not llm_model: + print(" Warning: Missing 'llm_model' config, using template reports") + return + + try: + self._llm_model = llm_model + self._llm_api_base = str( + sagellm_cfg.get("base_url") + or sagellm_cfg.get("api_base") + or "http://127.0.0.1:8000/v1" + ).rstrip("/") + self._llm_api_key = str(sagellm_cfg.get("api_key", "")) + self.llm_service = "openai_compatible" + print(" โœ“ sageLLM/OpenAI-compatible service ready") + except Exception as exc: # pragma: no cover - runtime dependency issues + print( + f" Warning: Failed to initialize sageLLM service ({exc}), using template reports" + ) + self.llm_service = None + + def generate( + self, + image_features: dict[str, Any], + patient_info: dict[str, Any] | None, + similar_cases: list[dict], + medical_knowledge: list[dict], + ) -> DiagnosisReport: + """ + ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Š + + Args: + image_features: ๅฝฑๅƒ็‰นๅพ + patient_info: ๆ‚ฃ่€…ไฟกๆฏ + similar_cases: ็›ธไผผ็—…ไพ‹ + medical_knowledge: ๅŒปๅญฆ็Ÿฅ่ฏ† + + Returns: + DiagnosisReport: ่ฏŠๆ–ญๆŠฅๅ‘Š + """ + # Step 1: ๆž„ๅปบๆ็คบ่ฏ + prompt = self._build_prompt(image_features, patient_info, similar_cases, medical_knowledge) + + # Step 2: ่ฐƒ็”จLLM็”ŸๆˆๆŠฅๅ‘Š + if self.llm_service is not None: + try: + report_text = self._generate_with_llm(prompt) + except Exception as exc: # pragma: no cover - network/runtime failures + print(f" Warning: sageLLM generation failed ({exc}), falling back to template") + report_text = self._generate_template_report( + image_features, patient_info, similar_cases + ) + else: + report_text = self._generate_template_report( + image_features, patient_info, similar_cases + ) + + # Step 3: ๆๅ–่ฏŠๆ–ญๅ’Œๅปบ่ฎฎ + diagnosis_summary, findings, recommendations = self._parse_report( + report_text, image_features + ) + + # ๆๅ–่ฏŠๆ–ญๅˆ—่กจ + diagnoses_list = [] + for abnorm in image_features.get("abnormalities", []): + if abnorm["type"] == "disc_herniation": + diagnoses_list.append(f"{abnorm['location']}ๆคŽ้—ด็›˜็ชๅ‡บ็—‡") + elif abnorm["type"] == "disc_degeneration": + diagnoses_list.append(f"{abnorm['location']}ๆคŽ้—ด็›˜้€€่กŒๆ€งๅ˜") + + if not diagnoses_list: + diagnoses_list = ["ๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธ"] + + # Step 4: ่ฎก็ฎ—็ฝฎไฟกๅบฆ + confidence = self._calculate_confidence(image_features, similar_cases) + + # ่Žทๅ–ๆ—ถ้—ดๆˆณ + from datetime import datetime + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + return DiagnosisReport( + diagnoses=diagnoses_list, + confidence=confidence, + findings=findings, + recommendations=recommendations, + similar_cases=similar_cases, + report=report_text, + timestamp=timestamp, + quality_score=image_features.get("image_quality", 0.0), + ) + + def _build_prompt( + self, + image_features: dict, + patient_info: dict | None, + similar_cases: list[dict], + medical_knowledge: list[dict], + ) -> str: + """ๆž„ๅปบLLMๆ็คบ่ฏ""" + prompt_parts = [ + "ไฝ ๆ˜ฏไธ€ไฝ็ป้ชŒไธฐๅฏŒ็š„่„ŠๆŸฑๅค–็ง‘ๅŒป็”Ÿ๏ผŒๆญฃๅœจไธบๆ‚ฃ่€…ๆ’ฐๅ†™่…ฐๆคŽMRI่ฏŠๆ–ญๆŠฅๅ‘Šใ€‚", + "", + "## ๅฝฑๅƒๅˆ†ๆž็ป“ๆžœ", + ] + + # ๆทปๅŠ ๅฝฑๅƒๅ‘็Žฐ + if image_features.get("abnormalities"): + prompt_parts.append("\n### ไธป่ฆๅ‘็Žฐ:") + for abnorm in image_features["abnormalities"]: + prompt_parts.append( + f"- {abnorm['location']}: {abnorm['description']} " + f"(ไธฅ้‡็จ‹ๅบฆ: {abnorm['severity']})" + ) + + # ๆทปๅŠ ๆ‚ฃ่€…ไฟกๆฏ + if patient_info: + prompt_parts.append("\n## ๆ‚ฃ่€…ไฟกๆฏ") + if "age" in patient_info: + prompt_parts.append(f"ๅนด้พ„: {patient_info['age']}ๅฒ") + if "gender" in patient_info: + prompt_parts.append(f"ๆ€งๅˆซ: {patient_info['gender']}") + if "symptoms" in patient_info: + prompt_parts.append(f"ไธป่ฏ‰: {patient_info['symptoms']}") + + # ๆทปๅŠ ็›ธไผผ็—…ไพ‹ๅ‚่€ƒ + if similar_cases: + prompt_parts.append("\n## ็›ธไผผ็—…ไพ‹ๅ‚่€ƒ") + for i, case in enumerate(similar_cases[:3], 1): + prompt_parts.append(f"\n็—…ไพ‹{i}:") + prompt_parts.append(f" ่ฏŠๆ–ญ: {case.get('diagnosis', 'N/A')}") + prompt_parts.append(f" ๆฒป็–—: {case.get('treatment', 'N/A')}") + + # ๆทปๅŠ ๅŒปๅญฆ็Ÿฅ่ฏ† + if medical_knowledge: + prompt_parts.append("\n## ็›ธๅ…ณๅŒปๅญฆ็Ÿฅ่ฏ†") + for knowledge in medical_knowledge: + prompt_parts.append(f"- {knowledge.get('content', '')}") + + prompt_parts.append("\n## ไปปๅŠก") + prompt_parts.append("่ฏทๅŸบไบŽไปฅไธŠไฟกๆฏ๏ผŒๆ’ฐๅ†™ไธ€ไปฝไธ“ไธš็š„่ฏŠๆ–ญๆŠฅๅ‘Š๏ผŒๅŒ…ๆ‹ฌ:") + prompt_parts.append("1. ๅฝฑๅƒๆ่ฟฐ") + prompt_parts.append("2. ่ฏŠๆ–ญ็ป“่ฎบ") + prompt_parts.append("3. ๆฒป็–—ๅปบ่ฎฎ") + + return "\n".join(prompt_parts) + + def _generate_with_llm(self, prompt: str) -> str: + """ไฝฟ็”จLLM็”ŸๆˆๆŠฅๅ‘Š""" + if self.llm_service is None or not self._llm_api_base or not self._llm_model: + raise RuntimeError("LLM service not initialized") + + options = {k: v for k, v in self._llm_generate_options.items() if v is not None} + headers = {"Content-Type": "application/json"} + if self._llm_api_key: + headers["Authorization"] = f"Bearer {self._llm_api_key}" + + payload = { + "model": self._llm_model, + "messages": [ + {"role": "system", "content": "ไฝ ๆ˜ฏไธ€ไฝไธ“ไธš่„ŠๆŸฑๅค–็ง‘ๅŒป็”Ÿใ€‚"}, + {"role": "user", "content": prompt}, + ], + "temperature": options.get("temperature", 0.7), + "top_p": options.get("top_p", 0.95), + "max_tokens": options.get("max_tokens", 768), + } + + response = requests.post( + f"{self._llm_api_base}/chat/completions", + json=payload, + headers=headers, + timeout=60, + ) + response.raise_for_status() + data = response.json() + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError("sageLLM service returned no choices") + + message = choices[0].get("message") or {} + text = message.get("content") or choices[0].get("text", "") + if not text: + raise RuntimeError("sageLLM service returned empty content") + return str(text).strip() + + def _resolve_generation_options(self, sagellm_cfg: dict[str, Any]) -> dict[str, Any]: + """่งฃๆž้‡‡ๆ ท/็”Ÿๆˆๅ‚ๆ•ฐไพ›ๆฏๆฌก่ฏทๆฑ‚ไฝฟ็”จ""" + + sampling_cfg = ( + sagellm_cfg.get("sampling", {}) if isinstance(sagellm_cfg.get("sampling"), dict) else {} + ) + + def _get_option(key: str, default: Any): + if sampling_cfg.get(key) is not None: + return sampling_cfg[key] + if sagellm_cfg.get(key) is not None: + return sagellm_cfg[key] + return default + + try: + max_tokens = int(_get_option("max_tokens", 768)) + except (TypeError, ValueError): + max_tokens = 768 + + try: + temperature = float(_get_option("temperature", 0.7)) + except (TypeError, ValueError): + temperature = 0.7 + + try: + top_p = float(_get_option("top_p", 0.95)) + except (TypeError, ValueError): + top_p = 0.95 + + return { + "max_tokens": max(1, max_tokens), + "temperature": max(0.0, temperature), + "top_p": min(max(top_p, 0.0), 1.0), + } + + def __del__(self): # pragma: no cover - best-effort cleanup + with contextlib.suppress(Exception): + self.llm_service = None + + def _generate_template_report( + self, + image_features: dict, + patient_info: dict | None, + similar_cases: list[dict], + ) -> str: + """ไฝฟ็”จๆจกๆฟ็”ŸๆˆๆŠฅๅ‘Š๏ผˆๆผ”็คบ็”จ๏ผ‰""" + report_parts = [] + + # ๆŠฅๅ‘Šๅคด + report_parts.append("=" * 60) + report_parts.append("่…ฐๆคŽMRI่ฏŠๆ–ญๆŠฅๅ‘Š") + report_parts.append("=" * 60) + report_parts.append(f"ๆŠฅๅ‘Šๆ—ถ้—ด: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + report_parts.append("") + + # ๆ‚ฃ่€…ไฟกๆฏ + if patient_info: + report_parts.append("ใ€ๆ‚ฃ่€…ไฟกๆฏใ€‘") + if "age" in patient_info: + report_parts.append(f"ๅนด้พ„: {patient_info['age']}ๅฒ") + if "gender" in patient_info: + gender_cn = "็”ท" if patient_info["gender"] == "male" else "ๅฅณ" + report_parts.append(f"ๆ€งๅˆซ: {gender_cn}") + if "symptoms" in patient_info: + report_parts.append(f"ไธป่ฏ‰: {patient_info['symptoms']}") + report_parts.append("") + + # ๅฝฑๅƒๆ่ฟฐ + report_parts.append("ใ€ๅฝฑๅƒๆ่ฟฐใ€‘") + report_parts.append("่…ฐๆคŽๅบๅˆ—ๆญฃๅธธ๏ผŒๅฏ่งL1-L5ๆคŽไฝ“ๅŠL1/L2-L5/S1ๆคŽ้—ด็›˜ใ€‚") + report_parts.append("") + + # ไธป่ฆๅ‘็Žฐ + report_parts.append("ใ€ไธป่ฆๅ‘็Žฐใ€‘") + abnormalities = image_features.get("abnormalities", []) + + if abnormalities: + for abnorm in abnormalities: + severity_cn = { + "mild": "่ฝปๅบฆ", + "moderate": "ไธญๅบฆ", + "severe": "้‡ๅบฆ", + }.get(abnorm.get("severity", "mild"), "่ฝปๅบฆ") + + report_parts.append( + f"{abnorm['location']}: {abnorm['description']} ({severity_cn})" + ) + else: + report_parts.append("ๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธใ€‚") + + report_parts.append("") + + # ่ฏŠๆ–ญ็ป“่ฎบ + report_parts.append("ใ€่ฏŠๆ–ญ็ป“่ฎบใ€‘") + diagnoses = [] + + for abnorm in abnormalities: + if abnorm["type"] == "disc_herniation": + diagnoses.append(f"{abnorm['location']}ๆคŽ้—ด็›˜็ชๅ‡บ็—‡") + elif abnorm["type"] == "disc_degeneration": + diagnoses.append(f"{abnorm['location']}ๆคŽ้—ด็›˜้€€่กŒๆ€งๅ˜") + + if diagnoses: + for i, diag in enumerate(diagnoses, 1): + report_parts.append(f"{i}. {diag}") + else: + report_parts.append("่…ฐๆคŽMRIๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธใ€‚") + + report_parts.append("") + + # ๆฒป็–—ๅปบ่ฎฎ + report_parts.append("ใ€ๅปบ่ฎฎใ€‘") + + if any(a["type"] == "disc_herniation" for a in abnormalities): + report_parts.append("1. ๅปบ่ฎฎไผ‘ๆฏ๏ผŒ้ฟๅ…้‡ไฝ“ๅŠ›ๅŠณๅŠจ") + report_parts.append("2. ๅฏ่€ƒ่™‘็‰ฉ็†ๆฒป็–—ๅ’Œ่ฏ็‰ฉๆฒป็–—") + report_parts.append("3. ๅฆ‚ไฟๅฎˆๆฒป็–—ๆ— ๆ•ˆ๏ผŒๅฏ่€ƒ่™‘ๅพฎๅˆ›ๆˆ–ๆ‰‹ๆœฏๆฒป็–—") + report_parts.append("4. ๅปบ่ฎฎ่„ŠๆŸฑๅค–็ง‘้—จ่ฏŠ้š่ฏŠ") + elif any(a["type"] == "disc_degeneration" for a in abnormalities): + report_parts.append("1. ๆณจๆ„่…ฐ้ƒจไฟๆŠค๏ผŒ้ฟๅ…ไน…ๅไน…็ซ™") + report_parts.append("2. ้€‚ๅฝ“ๅŠŸ่ƒฝ้”ป็‚ผ๏ผŒๅขžๅผบ่…ฐ่ƒŒ่‚ŒๅŠ›้‡") + report_parts.append("3. ๅฟ…่ฆๆ—ถๅฏ่กŒไฟๅฎˆๆฒป็–—") + report_parts.append("4. ๅฎšๆœŸๅคๆŸฅ") + else: + report_parts.append("1. ไฟๆŒ่‰ฏๅฅฝ็”Ÿๆดปไน ๆƒฏ") + report_parts.append("2. ้€‚ๅฝ“่ฟๅŠจ้”ป็‚ผ") + report_parts.append("3. ๅฆ‚ๆœ‰็—‡็Šถๅ˜ๅŒ–๏ผŒๅŠๆ—ถๅฐฑ่ฏŠ") + + report_parts.append("") + + # ็›ธไผผ็—…ไพ‹ๅ‚่€ƒ + if similar_cases: + report_parts.append("ใ€ๅ‚่€ƒ็—…ไพ‹ใ€‘") + report_parts.append(f"ๆฃ€็ดขๅˆฐ {len(similar_cases)} ไธช็›ธไผผ็—…ไพ‹ไพ›ๅ‚่€ƒใ€‚") + + report_parts.append("") + report_parts.append("=" * 60) + report_parts.append("ๆณจ: ๆœฌๆŠฅๅ‘Š็”ฑAI่พ…ๅŠฉ็”Ÿๆˆ๏ผŒไป…ไพ›ๅ‚่€ƒ๏ผŒๆœ€็ปˆ่ฏŠๆ–ญ้œ€็”ฑไธ“ไธšๅŒปๅธˆ็กฎ่ฎคใ€‚") + report_parts.append("=" * 60) + + return "\n".join(report_parts) + + def _parse_report(self, report_text: str, image_features: dict) -> tuple: + """่งฃๆžๆŠฅๅ‘Šๆๅ–่ฏŠๆ–ญๅ’Œๅปบ่ฎฎ""" + # ไปŽๆŠฅๅ‘Šไธญๆๅ–่ฏŠๆ–ญ + diagnoses = [] + findings = [] + recommendations = [] + + # ๆๅ–ๅผ‚ๅธธๅ‘็Žฐ + for abnorm in image_features.get("abnormalities", []): + findings.append(f"{abnorm['location']}: {abnorm['description']}") + + if abnorm["type"] == "disc_herniation": + diagnoses.append(f"{abnorm['location']}ๆคŽ้—ด็›˜็ชๅ‡บ็—‡") + elif abnorm["type"] == "disc_degeneration": + diagnoses.append(f"{abnorm['location']}ๆคŽ้—ด็›˜้€€่กŒๆ€งๅ˜") + + # ็”Ÿๆˆๅปบ่ฎฎ + has_herniation = any( + a["type"] == "disc_herniation" for a in image_features.get("abnormalities", []) + ) + + if has_herniation: + recommendations = [ + "ๅปบ่ฎฎไผ‘ๆฏ๏ผŒ้ฟๅ…้‡ไฝ“ๅŠ›ๅŠณๅŠจ", + "ๅฏ่€ƒ่™‘็‰ฉ็†ๆฒป็–—ๅ’Œ่ฏ็‰ฉๆฒป็–—", + "ๅฆ‚ไฟๅฎˆๆฒป็–—ๆ— ๆ•ˆ๏ผŒๅฏ่€ƒ่™‘ๅพฎๅˆ›ๆˆ–ๆ‰‹ๆœฏๆฒป็–—", + ] + else: + recommendations = ["ไฟๆŒ่‰ฏๅฅฝ็”Ÿๆดปไน ๆƒฏ", "้€‚ๅฝ“่ฟๅŠจ้”ป็‚ผ", "ๅฎšๆœŸๅคๆŸฅ"] + + diagnosis_summary = "; ".join(diagnoses) if diagnoses else "ๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธ" + + return diagnosis_summary, findings, recommendations + + def _calculate_confidence(self, image_features: dict, similar_cases: list[dict]) -> float: + """่ฎก็ฎ—่ฏŠๆ–ญ็ฝฎไฟกๅบฆ""" + confidence = 0.5 # ๅŸบ็ก€็ฝฎไฟกๅบฆ + + # ๅฝฑๅƒ่ดจ้‡ๅฝฑๅ“ + quality = image_features.get("image_quality", 0.5) + confidence += quality * 0.2 + + # ็›ธไผผ็—…ไพ‹ๆ•ฐ้‡ๅฝฑๅ“ + if len(similar_cases) >= 5: + confidence += 0.2 + elif len(similar_cases) >= 3: + confidence += 0.1 + + # ๅผ‚ๅธธๆ˜Ž็กฎๆ€งๅฝฑๅ“ + abnormalities = image_features.get("abnormalities", []) + if abnormalities: + # ๆœ‰ๆ˜Ž็กฎ็—…ๅ˜๏ผŒ็ฝฎไฟกๅบฆๆๅ‡ + confidence += 0.1 + + return min(confidence, 1.0) + + +if __name__ == "__main__": + # ๆต‹่ฏ• + config = {"models": {"llm_model": "Qwen/Qwen2.5-7B-Instruct"}} + + generator = ReportGenerator(config) + + # ๆจกๆ‹Ÿๆ•ฐๆฎ + image_features = { + "abnormalities": [ + { + "type": "disc_herniation", + "location": "L4/L5", + "severity": "moderate", + "description": "L4/L5 ๆคŽ้—ด็›˜็ชๅ‡บ", + } + ], + "image_quality": 0.85, + } + + result = generator.generate( + image_features=image_features, + patient_info={"age": 45, "gender": "male", "symptoms": "่…ฐ็—›"}, + similar_cases=[], + medical_knowledge=[], + ) + + print(result.report) diff --git a/apps/src/sage/apps/medical_diagnosis/config/agent_config.yaml b/apps/src/sage/apps/medical_diagnosis/config/agent_config.yaml new file mode 100644 index 0000000..08d5cd1 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/config/agent_config.yaml @@ -0,0 +1,126 @@ +# ๅŒป็–—่ฏŠๆ–ญAgent้…็ฝฎ + +agent: + name: LumbarSpineDiagnosticAgent + version: 1.0.0 + description: ่…ฐๆคŽMRI่ฏŠๆ–ญๆ™บ่ƒฝๅŠฉๆ‰‹ + max_iterations: 5 + timeout: 300 # ็ง’ + +models: + # ่ง†่ง‰็†่งฃๆจกๅž‹๏ผˆ็”จไบŽๅฝฑๅƒๅˆ†ๆž๏ผ‰ + vision_model: Qwen/Qwen2-VL-7B-Instruct + + # ่ฏญ่จ€ๆจกๅž‹๏ผˆ็”จไบŽๆŠฅๅ‘Š็”Ÿๆˆ๏ผ‰ + llm_model: Qwen/Qwen2.5-7B-Instruct + + # ๅตŒๅ…ฅๆจกๅž‹๏ผˆ็”จไบŽ็Ÿฅ่ฏ†ๆฃ€็ดข๏ผ‰ + embedding_model: BAAI/bge-large-zh-v1.5 + +services: + # sageLLMๆœๅŠก้…็ฝฎ + sagellm: + enabled: true + gpu_memory_utilization: 0.9 + tensor_parallel_size: 1 + max_model_len: 4096 + dtype: auto + + # EmbeddingๆœๅŠก้…็ฝฎ + embedding: + method: hf + cache_enabled: true + normalize: true + batch_size: 32 + + # ๅ‘้‡ๆ•ฐๆฎๅบ“้…็ฝฎ + vector_db: + backend: sage_db + collection_name: lumbar_spine_cases + dimension: 1024 + index_type: HNSW + metric_type: cosine + top_k: 5 + +image_processing: + # ๅ›พๅƒ้ข„ๅค„็† + input_size: [512, 512] + normalization: true + augmentation: false + + # ๅˆ†ๅ‰ฒๆจกๅž‹ + segmentation: + enabled: false # ๆฒกๆœ‰GPUๆˆ–ๆจกๅž‹ๆƒ้‡ๆ—ถๅฏไปฅๆš‚ๆ—ถๅ…ณ้—ญ + model: SAM # Segment Anything Model + checkpoint_path: .sage/models/sam_vit_h.pth # SAM ๆƒ้‡่ทฏๅพ„ + sam_variant: vit_h # vit_b / vit_l / vit_h + points_per_side: 16 + pred_iou_thresh: 0.86 + stability_score_thresh: 0.92 + min_mask_region_area: 200 + pixel_spacing_mm: 180.0 # MRI ๅƒ็ด ้—ด่ทไผฐ่ฎก๏ผˆmm๏ผ‰ + baseline_disc_height_mm: 9.0 # ๆญฃๅธธๆคŽ้—ด็›˜้ซ˜ๅบฆ๏ผˆmm๏ผ‰ + irregularity_threshold: 0.35 + lateral_shift_threshold: 0.18 + disc_levels: + - L1/L2 + - L2/L3 + - L3/L4 + - L4/L5 + - L5/S1 + thresholds: + min_height_ratio: 0.02 + max_height_ratio: 0.18 + min_aspect_ratio: 1.5 + min_width_ratio: 0.12 + min_vertical_pos: 0.08 + max_vertical_pos: 0.95 + min_confidence: 0.35 + min_separation_ratio: 0.05 + + # ็‰นๅพๆๅ– + feature_extraction: + method: clip # ็‰นๅพๆๅ–ๆ–นๆณ•: "clip" (ๆŽจ่) ๆˆ– "dinov2" + dimension: 768 # ็‰นๅพๅ‘้‡็ปดๅบฆ (CLIP: 512, DINOv2: 768) + +diagnosis: + # ่ฏŠๆ–ญ้˜ˆๅ€ผ + confidence_threshold: 0.7 + + # ็—…ๅ˜ๆฃ€ๆต‹ + detection: + disc_herniation_threshold: 0.8 + degeneration_threshold: 0.6 + + # ๆŠฅๅ‘Š็”Ÿๆˆ + report: + include_similar_cases: true + max_similar_cases: 5 + include_confidence_score: true + language: zh + +knowledge_base: + # ็Ÿฅ่ฏ†ๆฅๆบ + sources: + - medical_literature + - clinical_guidelines + - case_database + + # ๆ›ดๆ–ฐ็ญ–็•ฅ + update_frequency: weekly + auto_update: false + +output: + # ่พ“ๅ‡บๆ ผๅผ + format: structured # structured, json, pdf + save_path: output/diagnoses/ + + # ๅŒ…ๅซๅ†…ๅฎน + include_image_annotations: true + include_similar_cases: true + include_references: true + +logging: + level: INFO + save_logs: true + log_path: logs/medical_diagnosis/ diff --git a/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py b/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py new file mode 100644 index 0000000..9ebf9f7 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/run_diagnosis.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +่…ฐๆคŽMRIๅŒป็–—่ฏŠๆ–ญAgent - ไธป่ฟ่กŒ่„šๆœฌ + +ๅŠŸ่ƒฝ: +1. ๅ•ไธช็—…ไพ‹่ฏŠๆ–ญ +2. ๆ‰น้‡็—…ไพ‹ๅค„็† +3. ไบคไบ’ๅผ่ฏŠๆ–ญไผš่ฏ +""" + +import argparse +import subprocess +from pathlib import Path +from typing import Any + +from sage.apps.medical_diagnosis.agents import DiagnosticAgent + + +def check_and_setup_data(auto_setup: bool = False): + """ๆฃ€ๆŸฅๆ•ฐๆฎๆ˜ฏๅฆๅญ˜ๅœจ๏ผŒๅฆ‚ๆžœไธๅญ˜ๅœจๅˆ™่‡ชๅŠจไธ‹่ฝฝๅ’Œๅ‡†ๅค‡""" + # ่Žทๅ–ๅฝ“ๅ‰่„šๆœฌๆ‰€ๅœจ็›ฎๅฝ• + current_dir = Path(__file__).parent + data_dir = current_dir / "data" / "processed" + setup_script = current_dir / "setup_data.sh" + + # ๆฃ€ๆŸฅๆ•ฐๆฎๆ˜ฏๅฆๅญ˜ๅœจ + if data_dir.exists() and (data_dir / "train_index.json").exists(): + print(f"โœ“ ๆ•ฐๆฎ้›†ๅทฒๅฐฑ็ปช: {data_dir}") + return True + + print(f"โš ๏ธ ๆ•ฐๆฎ้›†ๆœชๆ‰พๅˆฐ: {data_dir}") + print("") + + # ๆฃ€ๆŸฅ setup_data.sh ๆ˜ฏๅฆๅญ˜ๅœจ + if not setup_script.exists(): + print(f"โŒ ๆ•ฐๆฎ่ฎพ็ฝฎ่„šๆœฌๆœชๆ‰พๅˆฐ: {setup_script}") + print("") + print("่ฏทๆ‰‹ๅŠจๅ‡†ๅค‡ๆ•ฐๆฎๆˆ–ๆฃ€ๆŸฅๅฎ‰่ฃ…ๆ˜ฏๅฆๅฎŒๆ•ด") + return False + + # ่‡ชๅŠจไธ‹่ฝฝๆ•ฐๆฎ + print(f"ๆ•ฐๆฎ้›†่ฎพ็ฝฎ่„šๆœฌ: {setup_script}") + print("") + print("๐Ÿค– ่‡ชๅŠจไธ‹่ฝฝๅนถๅ‡†ๅค‡ๆ•ฐๆฎ้›†...") + print("ๆ็คบ: ๅฆ‚ๆžœไธๆƒณ่‡ชๅŠจไธ‹่ฝฝ๏ผŒ่ฏทไฝฟ็”จ Ctrl+C ๅ–ๆถˆ") + print("") + print("ๅผ€ๅง‹่‡ชๅŠจ่ฎพ็ฝฎๆ•ฐๆฎ้›†...") + print("=" * 70) + + try: + # ่ฟ่กŒ setup_data.sh + subprocess.run( + ["bash", str(setup_script)], + cwd=str(current_dir), + check=True, + text=True, + capture_output=False, + ) + + print("=" * 70) + print("โœ… ๆ•ฐๆฎ้›†่ฎพ็ฝฎๅฎŒๆˆ๏ผ") + return True + + except subprocess.CalledProcessError: + print("=" * 70) + print("โŒ ๆ•ฐๆฎ้›†่ฎพ็ฝฎๅคฑ่ดฅ") + print("") + print("ๆ‚จๅฏไปฅๆ‰‹ๅŠจ่ฟ่กŒไปฅไธ‹ๅ‘ฝไปคๆฅ่ฎพ็ฝฎๆ•ฐๆฎ:") + print(f" bash {setup_script}") + print("") + print("ๆˆ–่€…ๆŸฅ็œ‹้”™่ฏฏๆ—ฅๅฟ—ไปฅ่Žทๅ–ๆ›ดๅคšไฟกๆฏ") + return False + except Exception as e: + print(f"โŒ ๅ‘็”Ÿ้”™่ฏฏ: {e}") + return False + + +def parse_args(): + """่งฃๆžๅ‘ฝไปค่กŒๅ‚ๆ•ฐ""" + parser = argparse.ArgumentParser( + description="่…ฐๆคŽMRIๅŒป็–—่ฏŠๆ–ญAgent", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +็คบไพ‹: + # ่ฏŠๆ–ญๅ•ไธช็—…ไพ‹ + python run_diagnosis.py --image data/medical/test/case_001.jpg --age 45 --gender male --symptoms "่…ฐ็—›ไผดๅทฆ่…ฟ้บปๆœจ" + + # ๆ‰น้‡ๅค„็† + python run_diagnosis.py --batch data/medical/batch_cases/ --output output/diagnoses/ + + # ไบคไบ’ๅผๆจกๅผ + python run_diagnosis.py --interactive + """, + ) + + # ๅ•ไธช็—…ไพ‹่ฏŠๆ–ญๅ‚ๆ•ฐ + parser.add_argument("--image", "-i", type=str, help="MRIๅฝฑๅƒ่ทฏๅพ„") + + parser.add_argument("--age", type=int, help="ๆ‚ฃ่€…ๅนด้พ„") + + parser.add_argument( + "--gender", type=str, choices=["male", "female"], help="ๆ‚ฃ่€…ๆ€งๅˆซ (male/female)" + ) + + parser.add_argument("--symptoms", type=str, help="ๆ‚ฃ่€…็—‡็Šถๆ่ฟฐ") + + # ๆ‰น้‡ๅค„็†ๅ‚ๆ•ฐ + parser.add_argument("--batch", type=str, help="ๆ‰น้‡ๅค„็†็›ฎๅฝ•") + + parser.add_argument( + "--output", + "-o", + type=str, + default="output/diagnoses/", + help="่พ“ๅ‡บ็›ฎๅฝ• (้ป˜่ฎค: output/diagnoses/)", + ) + + # ๅ…ถไป–ๅ‚ๆ•ฐ + parser.add_argument( + "--config", + "-c", + type=str, + default="examples/medical_diagnosis/config/agent_config.yaml", + help="้…็ฝฎๆ–‡ไปถ่ทฏๅพ„", + ) + + parser.add_argument("--interactive", action="store_true", help="ไบคไบ’ๅผๆจกๅผ") + + parser.add_argument("--verbose", "-v", action="store_true", help="ๆ˜พ็คบ่ฏฆ็ป†ไฟกๆฏ") + + parser.add_argument("--auto-setup", action="store_true", help="่‡ชๅŠจไธ‹่ฝฝๅนถ่ฎพ็ฝฎๆ•ฐๆฎ๏ผˆๆ— ้œ€็กฎ่ฎค๏ผ‰") + + return parser.parse_args() + + +def diagnose_single_case( + agent: DiagnosticAgent, + image_path: str, + patient_info: dict[str, Any] | None = None, + verbose: bool = True, +): + """่ฏŠๆ–ญๅ•ไธช็—…ไพ‹""" + print(f"\n{'=' * 70}") + print("๐Ÿฅ ่…ฐๆคŽMRI่ฏŠๆ–ญ็ณป็ปŸ") + print(f"{'=' * 70}\n") + + # ๆ‰ง่กŒ่ฏŠๆ–ญ + result = agent.diagnose(image_path=image_path, patient_info=patient_info, verbose=verbose) + + print(f"\n{'=' * 70}") + print("โœ… ่ฏŠๆ–ญๅฎŒๆˆ") + print(f"{'=' * 70}") + # DiagnosisReport ไฝฟ็”จ diagnoses (ๅคๆ•ฐ) ่€Œไธๆ˜ฏ diagnosis + if hasattr(result, "diagnoses") and result.diagnoses: + print(f"่ฏŠๆ–ญ: {', '.join(result.diagnoses)}") + print(f"็ฝฎไฟกๅบฆ: {result.confidence:.2%}") + + return result + + +def batch_diagnose(agent: DiagnosticAgent, batch_dir: str, output_dir: str): + """ๆ‰น้‡่ฏŠๆ–ญ""" + batch_path = Path(batch_dir) + + if not batch_path.exists(): + print(f"โŒ ้”™่ฏฏ: ๆ‰น้‡ๅค„็†็›ฎๅฝ•ไธๅญ˜ๅœจ: {batch_dir}") + return + + # ๆŸฅๆ‰พๆ‰€ๆœ‰ๅ›พๅƒๆ–‡ไปถ + image_files = [] + for ext in ["*.jpg", "*.jpeg", "*.png", "*.dcm"]: + image_files.extend(batch_path.glob(ext)) + + if not image_files: + print("โŒ ้”™่ฏฏ: ๆœชๆ‰พๅˆฐๅ›พๅƒๆ–‡ไปถ") + return + + print("\n๐Ÿฅ ๆ‰น้‡่ฏŠๆ–ญๆจกๅผ") + print(f"{'=' * 70}") + print(f"๐Ÿ“‚ ่พ“ๅ…ฅ็›ฎๅฝ•: {batch_dir}") + print(f"๐Ÿ“ ่พ“ๅ‡บ็›ฎๅฝ•: {output_dir}") + print(f"๐Ÿ“Š ็—…ไพ‹ๆ•ฐ้‡: {len(image_files)}") + print(f"{'=' * 70}\n") + + # ๆž„ๅปบ็—…ไพ‹ๅˆ—่กจ + cases = [{"image_path": str(img), "patient_info": {"case_id": img.stem}} for img in image_files] + + # ๆ‰น้‡ๅค„็† + results = agent.batch_diagnose(cases=cases, output_dir=output_dir) + + print("\n๐Ÿ“Š ๆ‰น้‡่ฏŠๆ–ญ็ปŸ่ฎก:") + print(f" ๆ€ป็—…ไพ‹ๆ•ฐ: {len(results)}") + + # ็ปŸ่ฎก่ฏŠๆ–ญ็ป“ๆžœ + diagnoses_count = {} + for result in results: + for diag in result.diagnoses: + diagnoses_count[diag] = diagnoses_count.get(diag, 0) + 1 + + print("\n ่ฏŠๆ–ญๅˆ†ๅธƒ:") + for diag, count in sorted(diagnoses_count.items(), key=lambda x: -x[1]): + print(f" - {diag}: {count} ไพ‹") + + +def interactive_mode(agent: DiagnosticAgent): + """ไบคไบ’ๅผ่ฏŠๆ–ญๆจกๅผ""" + print(f"\n{'=' * 70}") + print("๐Ÿฅ ่…ฐๆคŽMRI่ฏŠๆ–ญ็ณป็ปŸ - ไบคไบ’ๅผๆจกๅผ") + print(f"{'=' * 70}") + print("\n่พ“ๅ…ฅ 'exit' ๆˆ– 'quit' ้€€ๅ‡บ\n") + + while True: + print(f"\n{'โ”€' * 70}") + + # ่พ“ๅ…ฅๅฝฑๅƒ่ทฏๅพ„ + image_path = input("๐Ÿ“„ ่ฏท่พ“ๅ…ฅMRIๅฝฑๅƒ่ทฏๅพ„: ").strip() + + if image_path.lower() in ["exit", "quit"]: + print("\n๐Ÿ‘‹ ๅ†่ง๏ผ") + break + + if not image_path: + print("โŒ ่ทฏๅพ„ไธ่ƒฝไธบ็ฉบ") + continue + + # ่พ“ๅ…ฅๆ‚ฃ่€…ไฟกๆฏ + print("\n๐Ÿ‘ค ๆ‚ฃ่€…ไฟกๆฏ๏ผˆๅฏ้€‰๏ผŒ็›ดๆŽฅๅ›ž่ฝฆ่ทณ่ฟ‡๏ผ‰:") + + age_input = input(" ๅนด้พ„: ").strip() + age = int(age_input) if age_input.isdigit() else None + + gender_input = input(" ๆ€งๅˆซ (male/female): ").strip().lower() + gender = gender_input if gender_input in ["male", "female"] else None + + symptoms = input(" ็—‡็Šถ: ").strip() + + # ๆž„ๅปบๆ‚ฃ่€…ไฟกๆฏ + patient_info = {} + if age: + patient_info["age"] = age + if gender: + patient_info["gender"] = gender + if symptoms: + patient_info["symptoms"] = symptoms + + # ๆ‰ง่กŒ่ฏŠๆ–ญ + try: + agent.diagnose( + image_path=image_path, + patient_info=patient_info if patient_info else None, + verbose=True, + ) + except Exception as e: + print(f"\nโŒ ่ฏŠๆ–ญๅคฑ่ดฅ: {e}") + continue + + # ่ฏข้—ฎๆ˜ฏๅฆ็ปง็ปญ + continue_input = input("\nๆ˜ฏๅฆ็ปง็ปญ่ฏŠๆ–ญไธ‹ไธ€ไธช็—…ไพ‹? (y/n): ").strip().lower() + if continue_input != "y": + print("\n๐Ÿ‘‹ ๅ†่ง๏ผ") + break + + +def main(): + """ไธปๅ‡ฝๆ•ฐ""" + args = parse_args() + + # ๆฃ€ๆŸฅๅนถ่ฎพ็ฝฎๆ•ฐๆฎ๏ผˆๅฆ‚ๆžœ้œ€่ฆ๏ผ‰ + print(f"\n{'=' * 70}") + print("๐Ÿ“ฆ ๆฃ€ๆŸฅๆ•ฐๆฎ้›†็Šถๆ€...") + print(f"{'=' * 70}\n") + + if not check_and_setup_data(auto_setup=args.auto_setup): + print("\nโš ๏ธ ่ญฆๅ‘Š: ๆ•ฐๆฎ้›†ๆœชๅฐฑ็ปช") + print("็ณป็ปŸๅฐ†ไฝฟ็”จๆจกๆ‹Ÿๆ•ฐๆฎ่ฟ่กŒๆผ”็คบๆจกๅผ") + print("") + + # ๅˆๅง‹ๅŒ–Agent + print("\n๐Ÿš€ ๅˆๅง‹ๅŒ–่ฏŠๆ–ญAgent...") + print(f"{'=' * 70}") + + config_path = args.config if Path(args.config).exists() else None + agent = DiagnosticAgent(config_path=config_path) + + print(f"{'=' * 70}\n") + + # ๆ นๆฎๅ‚ๆ•ฐๆ‰ง่กŒไธๅŒๆจกๅผ + if args.interactive: + # ไบคไบ’ๅผๆจกๅผ + interactive_mode(agent) + + elif args.batch: + # ๆ‰น้‡ๅค„็†ๆจกๅผ + batch_diagnose(agent=agent, batch_dir=args.batch, output_dir=args.output) + + elif args.image: + # ๅ•ไธช็—…ไพ‹่ฏŠๆ–ญ + patient_info = {} + if args.age: + patient_info["age"] = args.age + if args.gender: + patient_info["gender"] = args.gender + if args.symptoms: + patient_info["symptoms"] = args.symptoms + + diagnose_single_case( + agent=agent, + image_path=args.image, + patient_info=patient_info if patient_info else None, + verbose=args.verbose, + ) + + else: + # ๆผ”็คบๆจกๅผ๏ผˆไฝฟ็”จๆจกๆ‹Ÿๆ•ฐๆฎ๏ผ‰ + print("\n๐Ÿ’ก ๆผ”็คบๆจกๅผ๏ผˆไฝฟ็”จๆจกๆ‹Ÿๆ•ฐๆฎ๏ผ‰\n") + print("ๆ็คบ: ไฝฟ็”จ --help ๆŸฅ็œ‹ๅฎŒๆ•ดไฝฟ็”จ่ฏดๆ˜Ž\n") + + # ๅˆ›ๅปบไธ€ไธชๆผ”็คบ็—…ไพ‹ + demo_image = "data/medical/demo/mri_case.jpg" + demo_patient = { + "age": 45, + "gender": "male", + "symptoms": "ไธ‹่ƒŒ้ƒจ็–ผ็—›๏ผŒๅทฆ่…ฟ้บปๆœจ๏ผŒๆŒ็ปญ2ๅ‘จ", + } + + diagnose_single_case( + agent=agent, image_path=demo_image, patient_info=demo_patient, verbose=True + ) + + +if __name__ == "__main__": + main() diff --git a/apps/src/sage/apps/medical_diagnosis/scripts/download_lumbar_dataset.py b/apps/src/sage/apps/medical_diagnosis/scripts/download_lumbar_dataset.py new file mode 100755 index 0000000..936e734 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/scripts/download_lumbar_dataset.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""ไธ‹่ฝฝ่…ฐๆคŽMRIๆ•ฐๆฎ้›†""" + +from pathlib import Path + +from datasets import Dataset, load_dataset +from huggingface_hub import snapshot_download + + +def download_lumbar_spine_dataset(): + """ไธ‹่ฝฝ UniDataPro/lumbar-spine-mri ๆ•ฐๆฎ้›†""" + + dataset_name = "UniDataPro/lumbar-spine-mri" + output_dir = Path("data/medical/lumbar-spine-mri") + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"๐Ÿ“ฅ ๅผ€ๅง‹ไธ‹่ฝฝๆ•ฐๆฎ้›†: {dataset_name}") + print(f"๐Ÿ“‚ ไฟๅญ˜่ทฏๅพ„: {output_dir.absolute()}") + + try: + # ๆ–นๅผ1: ไฝฟ็”จ datasets ๅบ“ๅŠ ่ฝฝ + print("\n๐Ÿ”„ ๆ–นๅผ1: ไฝฟ็”จ datasets ๅบ“ๅŠ ่ฝฝ...") + dataset = load_dataset(dataset_name, split="train") + + # ็กฎไฟๆ˜ฏ Dataset ็ฑปๅž‹ + if not isinstance(dataset, Dataset): + raise TypeError(f"Expected Dataset, got {type(dataset)}") + + print("โœ… ๆฎ้›†ๅŠ ่ฝฝๆˆๅŠŸ!") + print(f"๐Ÿ“Š ๆ ทๆœฌๆ•ฐ้‡: {len(dataset)}") + print(f"๐Ÿ“‹ ๅญ—ๆฎต: {dataset.column_names}") + + # ไฟๅญ˜ๆ•ฐๆฎ้›†ไฟกๆฏ + info_file = output_dir / "dataset_info.txt" + with open(info_file, "w", encoding="utf-8") as f: + f.write(f"Dataset: {dataset_name}\n") + f.write(f"Samples: {len(dataset)}\n") + f.write(f"Columns: {dataset.column_names}\n") + f.write("\nFirst sample:\n") + if len(dataset) > 0: + first_sample = dataset[0] # type: ignore[index] + if isinstance(first_sample, dict): + for key, value in first_sample.items(): + f.write(f" {key}: {type(value).__name__}\n") + + print(f"\n๐Ÿ’พ ๆ•ฐๆฎ้›†ไฟกๆฏๅทฒไฟๅญ˜ๅˆฐ: {info_file}") + + # ไฟๅญ˜ๆ•ฐๆฎ้›†ๅˆฐๆœฌๅœฐ + cache_dir = output_dir / "cache" + dataset.save_to_disk(str(cache_dir)) + print(f"๐Ÿ’พ ๆ•ฐๆฎ้›†ๅทฒ็ผ“ๅญ˜ๅˆฐ: {cache_dir}") + + return dataset + + except Exception as e: + print(f"โŒ ไธ‹่ฝฝๅคฑ่ดฅ: {e}") + print("\n๐Ÿ”„ ๅฐ่ฏ•ๆ–นๅผ2: ็›ดๆŽฅไธ‹่ฝฝไป“ๅบ“...") + + try: + snapshot_download( + repo_id=dataset_name, + repo_type="dataset", + local_dir=str(output_dir / "raw"), + local_dir_use_symlinks=False, + ) + print(f"โœ… ๆ•ฐๆฎ้›†ไป“ๅบ“ไธ‹่ฝฝๆˆๅŠŸ: {output_dir / 'raw'}") + except Exception as e2: + print(f"โŒ ๆ–นๅผ2ไนŸๅคฑ่ดฅ: {e2}") + raise + + +if __name__ == "__main__": + download_lumbar_spine_dataset() diff --git a/apps/src/sage/apps/medical_diagnosis/scripts/explore_dataset.py b/apps/src/sage/apps/medical_diagnosis/scripts/explore_dataset.py new file mode 100644 index 0000000..4286807 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/scripts/explore_dataset.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +ๆ•ฐๆฎ้›†ๆŽข็ดข่„šๆœฌ +็”จไบŽๆŸฅ็œ‹่…ฐๆคŽMRIๆ•ฐๆฎ้›†็š„ๆ ทๆœฌๅ’Œ็ปŸ่ฎกไฟกๆฏ +""" + +import sys +from collections import Counter +from pathlib import Path + +import numpy as np +from datasets import Dataset, load_from_disk +from PIL import Image + +# ่ฎพ็ฝฎ้กน็›ฎ่ทฏๅพ„ +project_root = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(project_root)) + + +def explore_dataset(): + """ๆŽข็ดขๆ•ฐๆฎ้›†""" + + dataset_path = project_root / "data" / "medical" / "lumbar-spine-mri" / "cache" + + if not dataset_path.exists(): + print(f"โŒ ๆ•ฐๆฎ้›†ไธๅญ˜ๅœจ: {dataset_path}") + print("่ฏทๅ…ˆ่ฟ่กŒ scripts/download_lumbar_dataset.py ไธ‹่ฝฝๆ•ฐๆฎ้›†") + return + + print("=" * 80) + print("๐Ÿ“Š ่…ฐๆคŽMRIๆ•ฐๆฎ้›†ๆŽข็ดข") + print("=" * 80) + + # ๅŠ ่ฝฝๆ•ฐๆฎ้›† + print(f"\n๐Ÿ“‚ ไปŽ {dataset_path} ๅŠ ่ฝฝๆ•ฐๆฎ้›†...") + dataset = load_from_disk(str(dataset_path)) + + # ็กฎไฟๆ˜ฏ Dataset ็ฑปๅž‹ + if not isinstance(dataset, Dataset): + raise TypeError(f"Expected Dataset, got {type(dataset)}") + + # ๅŸบๆœฌไฟกๆฏ + print("\n๐Ÿ“‹ ๆ•ฐๆฎ้›†ๅŸบๆœฌไฟกๆฏ:") + print(f" - ๆ ทๆœฌๆ€ปๆ•ฐ: {len(dataset)}") + print(f" - ๅญ—ๆฎต: {dataset.column_names}") + print(f" - Features: {dataset.features}") + + # ๆ ‡็ญพๅˆ†ๅธƒ + labels = [sample["label"] for sample in dataset] # type: ignore[index] + label_counts = Counter(labels) + + print("\n๐Ÿท๏ธ ๆ ‡็ญพๅˆ†ๅธƒ:") + for label, count in sorted(label_counts.items()): + percentage = count / len(dataset) * 100 + print(f" - Label {label}: {count} samples ({percentage:.1f}%)") + + # ๅ›พๅƒ็ปŸ่ฎก + print("\n๐Ÿ–ผ๏ธ ๅ›พๅƒ็ปŸ่ฎก (ๅ‰10ไธชๆ ทๆœฌ):") + image_sizes = [] + + for i in range(min(10, len(dataset))): + sample = dataset[i] + img = sample["image"] + + if isinstance(img, Image.Image): + width, height = img.size + mode = img.mode + image_sizes.append((width, height)) + + if i < 5: # ๅชๆ‰“ๅฐๅ‰5ไธช + print(f" - ๆ ทๆœฌ {i}: {width}x{height}, mode={mode}, label={sample['label']}") + + if image_sizes: + widths = [w for w, h in image_sizes] + heights = [h for w, h in image_sizes] + + print("\n๐Ÿ“ ๅ›พๅƒๅฐบๅฏธ่Œƒๅ›ด:") + print(f" - ๅฎฝๅบฆ: {min(widths)} ~ {max(widths)} (ๅนณๅ‡: {np.mean(widths):.0f})") + print(f" - ้ซ˜ๅบฆ: {min(heights)} ~ {max(heights)} (ๅนณๅ‡: {np.mean(heights):.0f})") + + # ไฟๅญ˜ไธ€ไบ›ๆ ทๆœฌ + output_dir = project_root / "examples" / "medical_diagnosis" / "data" / "samples" + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n๐Ÿ’พ ไฟๅญ˜ๆ ทๆœฌๅ›พๅƒๅˆฐ: {output_dir}") + + # ๆฏไธชๆ ‡็ญพไฟๅญ˜ไธ€ไธชๆ ทๆœฌ + saved_labels = set() + saved_count = 0 + + for i, sample in enumerate(dataset): # type: ignore[arg-type] + if not isinstance(sample, dict): + continue + label = sample["label"] + + if label not in saved_labels: + img = sample["image"] + if isinstance(img, Image.Image): + output_path = output_dir / f"sample_label{label}_{i}.jpg" + img.save(output_path) + print(f" โœ“ ไฟๅญ˜ label={label} ๆ ทๆœฌ: {output_path.name}") + saved_labels.add(label) + saved_count += 1 + + if saved_count >= 5: # ๆœ€ๅคšไฟๅญ˜5ไธชๆ ทๆœฌ + break + + # ๅˆ›ๅปบ็ฎ€ๅ•็š„็ปŸ่ฎกๆŠฅๅ‘Š + report_path = output_dir / "dataset_report.txt" + with open(report_path, "w", encoding="utf-8") as f: + f.write("่…ฐๆคŽMRIๆ•ฐๆฎ้›†็ปŸ่ฎกๆŠฅๅ‘Š\n") + f.write("=" * 80 + "\n\n") + f.write(f"ๆ•ฐๆฎ้›†่ทฏๅพ„: {dataset_path}\n") + f.write(f"ๆ ทๆœฌๆ€ปๆ•ฐ: {len(dataset)}\n") + f.write(f"ๅญ—ๆฎต: {', '.join(dataset.column_names)}\n\n") + + f.write("ๆ ‡็ญพๅˆ†ๅธƒ:\n") + for label, count in sorted(label_counts.items()): + percentage = count / len(dataset) * 100 + f.write(f" Label {label}: {count} ({percentage:.1f}%)\n") + + if image_sizes: + f.write("\nๅ›พๅƒๅฐบๅฏธ่Œƒๅ›ด:\n") + f.write(f" ๅฎฝๅบฆ: {min(widths)} ~ {max(widths)} (ๅนณๅ‡: {np.mean(widths):.0f})\n") + f.write(f" ้ซ˜ๅบฆ: {min(heights)} ~ {max(heights)} (ๅนณๅ‡: {np.mean(heights):.0f})\n") + + print(f"\n๐Ÿ“Š ็ปŸ่ฎกๆŠฅๅ‘Šๅทฒไฟๅญ˜: {report_path}") + + print("\n" + "=" * 80) + print("โœ… ๆ•ฐๆฎ้›†ๆŽข็ดขๅฎŒๆˆ!") + print("=" * 80) + + +if __name__ == "__main__": + explore_dataset() diff --git a/apps/src/sage/apps/medical_diagnosis/scripts/prepare_data.py b/apps/src/sage/apps/medical_diagnosis/scripts/prepare_data.py new file mode 100644 index 0000000..8c003e4 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/scripts/prepare_data.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +ๆ•ฐๆฎ้ข„ๅค„็†่„šๆœฌ +ๅฐ†ไธ‹่ฝฝ็š„ๆ•ฐๆฎ้›†่ฝฌๆขไธบ่ฎญ็ปƒ/ๆต‹่ฏ•้›†๏ผŒๅนถ็”Ÿๆˆๆจกๆ‹Ÿ็š„่ฏŠๆ–ญๆŠฅๅ‘Š +""" + +import json +import random +from pathlib import Path + +from datasets import load_from_disk +from PIL import Image +from sklearn.model_selection import train_test_split + +# Note: This script assumes sage-apps package is installed +# Install with: cd sage-apps && pip install -e . + +# ๆŸฅๆ‰พ้กน็›ฎๆ น็›ฎๅฝ• +_current_file = Path(__file__).resolve() +project_root = _current_file.parent.parent.parent.parent.parent.parent # ๅˆฐ sage-examples ๆ น็›ฎๅฝ• + + +# ็–พ็—…็ฑปๅˆซๆ˜ ๅฐ„ (ๆจกๆ‹Ÿ) +DISEASE_MAPPING = { + 0: {"name": "ๆญฃๅธธ", "severity": "ๆ— ", "description": "ๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธ"}, + 1: { + "name": "่ฝปๅบฆ้€€่กŒๆ€งๅ˜", + "severity": "่ฝปๅบฆ", + "description": "L4/L5ๆคŽ้—ด็›˜่ฝปๅบฆ้€€่กŒๆ€งๅ˜", + }, + 2: { + "name": "ๆคŽ้—ด็›˜็ชๅ‡บ", + "severity": "ไธญๅบฆ", + "description": "L4/L5ๆคŽ้—ด็›˜ๅ‘ๅŽ็ชๅ‡บ,ๅŽ‹่ฟซ็กฌ่†œๅ›Š", + }, + 3: { + "name": "ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜", + "severity": "ไธญๅบฆ", + "description": "L3/L4, L4/L5, L5/S1ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜", + }, + 4: { + "name": "ๆคŽ็ฎก็‹ญ็ช„", + "severity": "้‡ๅบฆ", + "description": "L4/L5ๆคŽ็ฎก็‹ญ็ช„,็กฌ่†œๅ›Šๅ—ๅŽ‹", + }, + 5: { + "name": "ๆคŽ้—ด็›˜่„ฑๅ‡บ", + "severity": "้‡ๅบฆ", + "description": "L5/S1ๆคŽ้—ด็›˜่„ฑๅ‡บ,ๆธธ็ฆปๅˆฐๆคŽ็ฎกๅ†…", + }, + 6: {"name": "้ชจ่ดจๅขž็”Ÿ", "severity": "่ฝปๅบฆ", "description": "L3-L5ๆคŽไฝ“็ผ˜้ชจ่ดจๅขž็”Ÿ"}, + 7: {"name": "ๆคŽไฝ“ๆป‘่„ฑ", "severity": "ไธญๅบฆ", "description": "L4ๆคŽไฝ“ๅ‰็งป,Iๅบฆๆป‘่„ฑ"}, + 8: {"name": "้Ÿงๅธฆ้’™ๅŒ–", "severity": "่ฝปๅบฆ", "description": "ๅŽ็บต้Ÿงๅธฆ้’™ๅŒ–"}, +} + + +def generate_mock_report(label: int, patient_info: dict) -> str: + """็”Ÿๆˆๆจกๆ‹Ÿ่ฏŠๆ–ญๆŠฅๅ‘Š""" + + disease_info = DISEASE_MAPPING.get(label, DISEASE_MAPPING[0]) + + age = patient_info.get("age", 45) + gender = patient_info.get("gender", "็”ท") + + # ๆ นๆฎ็–พ็—…็”Ÿๆˆ็—‡็Šถ + if label == 0: + symptoms = "ๆ— ๆ˜Žๆ˜พไธ้€‚" + findings = "่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: ๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธใ€‚ๅ„ๆคŽ้—ด็›˜ไฟกๅทๆญฃๅธธ๏ผŒๆคŽไฝ“ๆŽ’ๅˆ—ๆ•ด้ฝ๏ผŒๆคŽ็ฎก้€š็•…ใ€‚" + conclusion = "ๅฝฑๅƒๅญฆๆœช่งๆ˜Žๆ˜พๅผ‚ๅธธใ€‚" + recommendations = "ๅฎšๆœŸไฝ“ๆฃ€๏ผŒไฟๆŒ่‰ฏๅฅฝ็š„็”Ÿๆดปไน ๆƒฏใ€‚" + elif label in [1, 6, 8]: + symptoms = "ๅถๅฐ”่…ฐ้ƒจ้…ธ็—›" + findings = ( + f"่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: {disease_info['description']}ใ€‚ๆคŽ็ฎกๅฐš้€š็•…๏ผŒๆœช่งๆ˜Žๆ˜พ็ฅž็ปๆ นๅ—ๅŽ‹ใ€‚" + ) + conclusion = f"{disease_info['name']}๏ผŒ็จ‹ๅบฆ{disease_info['severity']}ใ€‚" + recommendations = ( + "้€‚ๅฝ“ไผ‘ๆฏ๏ผŒ้ฟๅ…ไน…ๅไน…็ซ™ใ€‚ๅฏ่ฟ›่กŒ่…ฐ่ƒŒ่‚Œ้”ป็‚ผ๏ผŒๅฆ‚ๆธธๆณณใ€ๆ™ฎๆ‹‰ๆ็ญ‰ใ€‚ๅฟ…่ฆๆ—ถ็‰ฉ็†ๆฒป็–—ใ€‚" + ) + elif label in [2, 3]: + symptoms = "่…ฐ็—›ไผดๅณไธ‹่‚ขๆ”พๅฐ„็—›3ๅ‘จ" + findings = f"่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: {disease_info['description']}ใ€‚็›ธๅบ”่Š‚ๆฎตๆคŽ็ฎกๅ˜็ช„๏ผŒ็ฅž็ปๆ นๅฏ่ƒฝๅ—ๅŽ‹ใ€‚" + conclusion = f"{disease_info['name']}๏ผŒ็จ‹ๅบฆ{disease_info['severity']}ใ€‚" + recommendations = "ๅปบ่ฎฎๅงๅบŠไผ‘ๆฏ2-3ๅ‘จ๏ผŒ็‰ตๅผ•ๆฒป็–—ใ€‚ๅฃๆœ้ž็”พไฝ“ๆŠ—็‚Ž่ฏๅŠ็ฅž็ป่ฅๅ…ป่ฏ็‰ฉใ€‚ไฟๅฎˆๆฒป็–—ๆ— ๆ•ˆๆ—ถ่€ƒ่™‘ๆ‰‹ๆœฏๆฒป็–—ใ€‚" + elif label == 7: + symptoms = "่…ฐ้ƒจ็–ผ็—›๏ผŒๆดปๅŠจๅ—้™" + findings = f"่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: {disease_info['description']}ใ€‚็›ธๅบ”่Š‚ๆฎตไธ็จณๅฎšใ€‚" + conclusion = f"{disease_info['name']}๏ผŒ็จ‹ๅบฆ{disease_info['severity']}ใ€‚" + recommendations = "้ฟๅ…้‡ไฝ“ๅŠ›ๅŠณๅŠจ๏ผŒไฝฉๆˆด่…ฐๅ›ดไฟๆŠคใ€‚ๆ ธๅฟƒ่‚Œ็พค่ฎญ็ปƒใ€‚็—‡็Šถๆ˜Žๆ˜พๆ—ถ่€ƒ่™‘ๆ‰‹ๆœฏๅ›บๅฎšใ€‚" + else: # ้‡ๅบฆ (4, 5) + symptoms = "่…ฐ็—›ไผดๅŒไธ‹่‚ข้บปๆœจใ€ๆ— ๅŠ›2ๆœˆ" + findings = f"่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: {disease_info['description']}ใ€‚้ฉฌๅฐพ็ฅž็ปๅ—ๅŽ‹ใ€‚" + conclusion = f"{disease_info['name']}๏ผŒ็จ‹ๅบฆ{disease_info['severity']}ใ€‚" + recommendations = ( + "ๅปบ่ฎฎๅฐฝๆ—ฉๆ‰‹ๆœฏๆฒป็–—(ๆคŽ้—ด็›˜ๆ‘˜้™คๆœฏๆˆ–ๆคŽ็ฎกๅ‡ๅŽ‹ๆœฏ)๏ผŒไปฅ่งฃ้™ค็ฅž็ปๅŽ‹่ฟซใ€‚ๆœฏๅŽๅบทๅค่ฎญ็ปƒใ€‚" + ) + + report = f"""ๆ‚ฃ่€…ไฟกๆฏ: + ๅนด้พ„: {age}ๅฒ + ๆ€งๅˆซ: {gender} + ไธป่ฏ‰: {symptoms} + +ๅฝฑๅƒๆ่ฟฐ: + {findings} + +ไธป่ฆๅ‘็Žฐ: + - ็—…ๅ˜่Š‚ๆฎต: {"ๅคš่Š‚ๆฎต" if label == 3 else "L4/L5ๆˆ–L5/S1"} + - ็—…ๅ˜็ฑปๅž‹: {disease_info["name"]} + - ไธฅ้‡็จ‹ๅบฆ: {disease_info["severity"]} + +่ฏŠๆ–ญ็ป“่ฎบ: + {conclusion} + +ๆฒป็–—ๅปบ่ฎฎ: + {recommendations} + +ๆณจ: ๆœฌๆŠฅๅ‘Šไป…ไพ›ๅ‚่€ƒ๏ผŒ่ฏท็ป“ๅˆไธดๅบŠ็—‡็Šถๅ’Œๅ…ถไป–ๆฃ€ๆŸฅ็ป“ๆžœ็ปผๅˆๅˆคๆ–ญใ€‚ +""" + + return report + + +def prepare_dataset(): + """ๅ‡†ๅค‡ๆ•ฐๆฎ้›†""" + + dataset_path = project_root / "data" / "medical" / "lumbar-spine-mri" / "cache" + output_dir = project_root / "examples" / "medical_diagnosis" / "data" / "processed" + output_dir.mkdir(parents=True, exist_ok=True) + + print("=" * 80) + print("๐Ÿ“Š ๆ•ฐๆฎ้ข„ๅค„็†") + print("=" * 80) + + # ๅŠ ่ฝฝๆ•ฐๆฎ้›† + print("\n๐Ÿ“‚ ๅŠ ่ฝฝๆ•ฐๆฎ้›†...") + dataset = load_from_disk(str(dataset_path)) + print(f" โœ“ ๅทฒๅŠ ่ฝฝ {len(dataset)} ไธชๆ ทๆœฌ") + + # ๅ‡†ๅค‡ๆ•ฐๆฎ + print("\n๐Ÿ”„ ๅ‡†ๅค‡ๆ•ฐๆฎ...") + + samples = [] + for i, sample in enumerate(dataset): # type: ignore[arg-type] + if not isinstance(sample, dict): + continue + label = sample["label"] + image = sample["image"] + + # ็”Ÿๆˆๆ‚ฃ่€…ไฟกๆฏ + age = random.randint(25, 75) + gender = random.choice(["็”ท", "ๅฅณ"]) + + patient_info = { + "age": age, + "gender": gender, + "patient_id": f"P{i + 1:04d}", + } + + # ็”Ÿๆˆ่ฏŠๆ–ญๆŠฅๅ‘Š + report = generate_mock_report(label, patient_info) + + # ไฟๅญ˜ๅ›พๅƒ + image_filename = f"case_{i + 1:04d}_label{label}.jpg" + image_path = output_dir / "images" / image_filename + image_path.parent.mkdir(exist_ok=True) + + if isinstance(image, Image.Image): + # ็ปŸไธ€resizeๅˆฐ512x512 + image_resized = image.resize((512, 512), Image.Resampling.LANCZOS) + image_resized.save(image_path, quality=95) + + # ไฟๅญ˜ๆŠฅๅ‘Š + report_filename = f"case_{i + 1:04d}_report.txt" + report_path = output_dir / "reports" / report_filename + report_path.parent.mkdir(exist_ok=True) + + with open(report_path, "w", encoding="utf-8") as f: + f.write(report) + + # ่ฎฐๅฝ•ๆ ทๆœฌไฟกๆฏ + disease_info = DISEASE_MAPPING.get(label, DISEASE_MAPPING[0]) + samples.append( + { + "case_id": f"case_{i + 1:04d}", + "patient_id": patient_info["patient_id"], + "age": age, + "gender": gender, + "label": label, + "disease": disease_info["name"], + "severity": disease_info["severity"], + "image_path": str(image_path.relative_to(output_dir)), + "report_path": str(report_path.relative_to(output_dir)), + } + ) + + if (i + 1) % 20 == 0: + print(f" ๅค„็†่ฟ›ๅบฆ: {i + 1}/{len(dataset)}") + + print(f" โœ“ ๅทฒๅค„็† {len(samples)} ไธช็—…ไพ‹") + + # ๅˆ’ๅˆ†่ฎญ็ปƒ/ๆต‹่ฏ•้›† (80/20) + print("\nโœ‚๏ธ ๅˆ’ๅˆ†่ฎญ็ปƒ/ๆต‹่ฏ•้›†...") + + train_samples, test_samples = train_test_split( + samples, test_size=0.2, random_state=42, stratify=[s["label"] for s in samples] + ) + + print(f" โœ“ ่ฎญ็ปƒ้›†: {len(train_samples)} ๆ ทๆœฌ") + print(f" โœ“ ๆต‹่ฏ•้›†: {len(test_samples)} ๆ ทๆœฌ") + + # ไฟๅญ˜็ดขๅผ•ๆ–‡ไปถ + print("\n๐Ÿ’พ ไฟๅญ˜็ดขๅผ•ๆ–‡ไปถ...") + + # ไฟๅญ˜JSONๆ ผๅผ + with open(output_dir / "train_index.json", "w", encoding="utf-8") as f: + json.dump(train_samples, f, ensure_ascii=False, indent=2) + + with open(output_dir / "test_index.json", "w", encoding="utf-8") as f: + json.dump(test_samples, f, ensure_ascii=False, indent=2) + + with open(output_dir / "all_cases.json", "w", encoding="utf-8") as f: + json.dump(samples, f, ensure_ascii=False, indent=2) + + print(" โœ“ train_index.json") + print(" โœ“ test_index.json") + print(" โœ“ all_cases.json") + + # ็”Ÿๆˆ็ปŸ่ฎกๆŠฅๅ‘Š + print("\n๐Ÿ“Š ็”Ÿๆˆ็ปŸ่ฎกๆŠฅๅ‘Š...") + + stats = { + "total_samples": len(samples), + "train_samples": len(train_samples), + "test_samples": len(test_samples), + "label_distribution": {}, + "disease_distribution": {}, + "severity_distribution": {}, + "age_range": [min(s["age"] for s in samples), max(s["age"] for s in samples)], + "gender_distribution": { + "็”ท": sum(1 for s in samples if s["gender"] == "็”ท"), + "ๅฅณ": sum(1 for s in samples if s["gender"] == "ๅฅณ"), + }, + } + + for sample in samples: + label = sample["label"] + disease = sample["disease"] + severity = sample["severity"] + + stats["label_distribution"][label] = stats["label_distribution"].get(label, 0) + 1 + stats["disease_distribution"][disease] = stats["disease_distribution"].get(disease, 0) + 1 + stats["severity_distribution"][severity] = ( + stats["severity_distribution"].get(severity, 0) + 1 + ) + + with open(output_dir / "stats.json", "w", encoding="utf-8") as f: + json.dump(stats, f, ensure_ascii=False, indent=2) + + # ็”Ÿๆˆๅฏ่ฏป็š„็ปŸ่ฎกๆŠฅๅ‘Š + with open(output_dir / "README.txt", "w", encoding="utf-8") as f: + f.write("่…ฐๆคŽMRIๆ•ฐๆฎ้›† - ๅค„็†ๅŽ\n") + f.write("=" * 80 + "\n\n") + f.write(f"ๆ€ปๆ ทๆœฌๆ•ฐ: {stats['total_samples']}\n") + f.write(f"่ฎญ็ปƒ้›†: {stats['train_samples']}\n") + f.write(f"ๆต‹่ฏ•้›†: {stats['test_samples']}\n\n") + + f.write("็–พ็—…ๅˆ†ๅธƒ:\n") + for disease, count in sorted(stats["disease_distribution"].items(), key=lambda x: -x[1]): + percentage = count / stats["total_samples"] * 100 + f.write(f" - {disease}: {count} ({percentage:.1f}%)\n") + + f.write("\nไธฅ้‡็จ‹ๅบฆๅˆ†ๅธƒ:\n") + for severity, count in stats["severity_distribution"].items(): + percentage = count / stats["total_samples"] * 100 + f.write(f" - {severity}: {count} ({percentage:.1f}%)\n") + + f.write(f"\nๅนด้พ„่Œƒๅ›ด: {stats['age_range'][0]} - {stats['age_range'][1]} ๅฒ\n") + f.write("\nๆ€งๅˆซๅˆ†ๅธƒ:\n") + for gender, count in stats["gender_distribution"].items(): + percentage = count / stats["total_samples"] * 100 + f.write(f" - {gender}: {count} ({percentage:.1f}%)\n") + + f.write("\n็›ฎๅฝ•็ป“ๆž„:\n") + f.write(" - images/: MRIๅฝฑๅƒๆ–‡ไปถ (512x512 JPG)\n") + f.write(" - reports/: ่ฏŠๆ–ญๆŠฅๅ‘Šๆ–‡ไปถ (TXT)\n") + f.write(" - train_index.json: ่ฎญ็ปƒ้›†็ดขๅผ•\n") + f.write(" - test_index.json: ๆต‹่ฏ•้›†็ดขๅผ•\n") + f.write(" - all_cases.json: ๆ‰€ๆœ‰็—…ไพ‹็ดขๅผ•\n") + f.write(" - stats.json: ็ปŸ่ฎกไฟกๆฏ (JSON)\n") + f.write(" - README.txt: ๆœฌๆ–‡ไปถ\n") + + print(" โœ“ stats.json") + print(" โœ“ README.txt") + + print("\n" + "=" * 80) + print("โœ… ๆ•ฐๆฎ้ข„ๅค„็†ๅฎŒๆˆ!") + print(f"๐Ÿ“ ่พ“ๅ‡บ็›ฎๅฝ•: {output_dir}") + print("=" * 80) + + # ๆ˜พ็คบไธ€ไบ›็ปŸ่ฎกไฟกๆฏ + print("\n๐Ÿ“Š ๆ•ฐๆฎ้›†็ปŸ่ฎก:") + print(f" - ๆ€ปๆ ทๆœฌ: {stats['total_samples']}") + print(f" - ่ฎญ็ปƒ้›†: {stats['train_samples']}") + print(f" - ๆต‹่ฏ•้›†: {stats['test_samples']}") + print(f"\n๐Ÿฅ ็–พ็—…็ฑปๅž‹: {len(stats['disease_distribution'])} ็ง") + for disease, count in sorted(stats["disease_distribution"].items(), key=lambda x: -x[1])[:5]: + print(f" - {disease}: {count}") + + +if __name__ == "__main__": + prepare_dataset() diff --git a/apps/src/sage/apps/medical_diagnosis/setup_data.sh b/apps/src/sage/apps/medical_diagnosis/setup_data.sh new file mode 100755 index 0000000..3092690 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/setup_data.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# ๅŒป็–—่ฏŠๆ–ญๆ•ฐๆฎ้›†่‡ชๅŠจไธ‹่ฝฝๅ’Œๅ‡†ๅค‡่„šๆœฌ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +DATA_DIR="$SCRIPT_DIR/data/processed" + +echo "==========================================" +echo "ๅŒป็–—่ฏŠๆ–ญๆ•ฐๆฎ้›†่‡ชๅŠจ่ฎพ็ฝฎ" +echo "==========================================" + +# ๆฃ€ๆŸฅๆ˜ฏๅฆๅทฒ็ปๅญ˜ๅœจๅค„็†ๅฅฝ็š„ๆ•ฐๆฎ +if [ -d "$DATA_DIR" ] && [ -f "$DATA_DIR/train_index.json" ]; then + echo "โœ“ ๆ•ฐๆฎ้›†ๅทฒๅญ˜ๅœจ: $DATA_DIR" + echo "" + + # ๆ˜พ็คบๆ•ฐๆฎ้›†็ปŸ่ฎก + if [ -f "$DATA_DIR/stats.json" ]; then + echo "๐Ÿ“Š ๆ•ฐๆฎ้›†็ปŸ่ฎก:" + python3 -c " +import json +with open('$DATA_DIR/stats.json', 'r') as f: + stats = json.load(f) + print(f\" ๆ€ปๆ ทๆœฌๆ•ฐ: {stats['total_samples']}\") + print(f\" ่ฎญ็ปƒ้›†: {stats['train_samples']}\") + print(f\" ๆต‹่ฏ•้›†: {stats['test_samples']}\") + print(f\" ็–พ็—…็ฑปๅž‹: {len(stats['disease_distribution'])}\") +" + fi + + echo "" + echo "ๅฆ‚้œ€้‡ๆ–ฐไธ‹่ฝฝ๏ผŒ่ฏทๅˆ ้™ค data/ ็›ฎๅฝ•ๅŽ้‡ๆ–ฐ่ฟ่กŒๆญค่„šๆœฌ" + exit 0 +fi + +echo "๐Ÿ“ฅ ๅผ€ๅง‹ไธ‹่ฝฝๅ’Œๅ‡†ๅค‡ๆ•ฐๆฎ้›†..." +echo "" + +# 1. ๅฎ‰่ฃ…ๅฟ…่ฆ็š„ไพ่ต– +echo "1๏ธโƒฃ ๆฃ€ๆŸฅPythonไพ่ต–..." +pip install -q huggingface_hub datasets pillow scikit-learn || { + echo "โŒ ไพ่ต–ๅฎ‰่ฃ…ๅคฑ่ดฅ" + exit 1 +} +echo " โœ“ ไพ่ต–ๅทฒๅฎ‰่ฃ…" + +# 2. ไธ‹่ฝฝๆ•ฐๆฎ้›† +echo "" +echo "2๏ธโƒฃ ไธ‹่ฝฝ่…ฐๆคŽMRIๆ•ฐๆฎ้›†..." +python3 "$PROJECT_ROOT/scripts/download_lumbar_dataset.py" || { + echo "โŒ ๆ•ฐๆฎ้›†ไธ‹่ฝฝๅคฑ่ดฅ" + exit 1 +} +echo " โœ“ ๆ•ฐๆฎ้›†ไธ‹่ฝฝๅฎŒๆˆ" + +# 3. ้ข„ๅค„็†ๆ•ฐๆฎ +echo "" +echo "3๏ธโƒฃ ้ข„ๅค„็†ๆ•ฐๆฎ้›†..." +python3 "$SCRIPT_DIR/scripts/prepare_data.py" || { + echo "โŒ ๆ•ฐๆฎ้ข„ๅค„็†ๅคฑ่ดฅ" + exit 1 +} +echo " โœ“ ๆ•ฐๆฎ้ข„ๅค„็†ๅฎŒๆˆ" + +# 4. ้ชŒ่ฏๆ•ฐๆฎ +echo "" +echo "4๏ธโƒฃ ้ชŒ่ฏๆ•ฐๆฎ้›†..." +if [ -f "$DATA_DIR/train_index.json" ] && [ -f "$DATA_DIR/test_index.json" ]; then + echo " โœ“ ๆ•ฐๆฎ้›†้ชŒ่ฏๆˆๅŠŸ" + + # ๆ˜พ็คบ็ปŸ่ฎกไฟกๆฏ + python3 -c " +import json +with open('$DATA_DIR/stats.json', 'r') as f: + stats = json.load(f) + print('') + print('๐Ÿ“Š ๆ•ฐๆฎ้›†ๅ‡†ๅค‡ๅฎŒๆˆ!') + print('=' * 50) + print(f\"ๆ€ปๆ ทๆœฌๆ•ฐ: {stats['total_samples']}\") + print(f\"่ฎญ็ปƒ้›†: {stats['train_samples']}\") + print(f\"ๆต‹่ฏ•้›†: {stats['test_samples']}\") + print(f\"็–พ็—…็ฑปๅž‹: {len(stats['disease_distribution'])}\") + print('=' * 50) +" +else + echo " โŒ ๆ•ฐๆฎ้›†้ชŒ่ฏๅคฑ่ดฅ" + exit 1 +fi + +echo "" +echo "โœ… ๆ•ฐๆฎ้›†่ฎพ็ฝฎๅฎŒๆˆ๏ผ" +echo "" +echo "ๅฏไปฅๅผ€ๅง‹ไฝฟ็”จ:" +echo " python examples/medical_diagnosis/test_diagnosis.py --mode single" +echo " python examples/medical_diagnosis/run_diagnosis.py --interactive" +echo "" diff --git a/apps/src/sage/apps/medical_diagnosis/tools/__init__.py b/apps/src/sage/apps/medical_diagnosis/tools/__init__.py new file mode 100644 index 0000000..3efb2aa --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/tools/__init__.py @@ -0,0 +1,5 @@ +"""ๅทฅๅ…ทๅŒ…""" + +from .knowledge_base import MedicalKnowledgeBase + +__all__ = ["MedicalKnowledgeBase"] diff --git a/apps/src/sage/apps/medical_diagnosis/tools/knowledge_base.py b/apps/src/sage/apps/medical_diagnosis/tools/knowledge_base.py new file mode 100644 index 0000000..a322040 --- /dev/null +++ b/apps/src/sage/apps/medical_diagnosis/tools/knowledge_base.py @@ -0,0 +1,720 @@ +""" +ๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“็ฎก็† +่ดŸ่ดฃๅŒปๅญฆ็Ÿฅ่ฏ†ๅ’Œ็—…ไพ‹็š„ๆฃ€็ดข +""" + +import hashlib +import json +from pathlib import Path +from typing import Any + +# SageVDB has been migrated to independent PyPI package +try: + from sagevdb import SageVDB + + SAGEDB_AVAILABLE = True +except ImportError: + SAGEDB_AVAILABLE = False + SageVDB = None # type: ignore + + +class _HashEmbedder: + """Deterministic lightweight embedder for example/test environments.""" + + def __init__(self, dim: int = 1024): + self._dim = dim + + def embed(self, text: str) -> list[float]: + if self._dim <= 0: + raise ValueError("Embedding dimension must be positive") + + vector = [0.0] * self._dim + tokens = [token for token in text.lower().split() if token] + if not tokens: + return vector + + for token in tokens: + digest = hashlib.sha256(token.encode("utf-8")).digest() + index = int.from_bytes(digest[:8], byteorder="little") % self._dim + vector[index] += 1.0 + + norm = sum(value * value for value in vector) ** 0.5 + if norm > 0: + vector = [value / norm for value in vector] + return vector + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + return [self.embed(text) for text in texts] + + def get_dim(self) -> int: + return self._dim + + +class MedicalKnowledgeBase: + """ + ๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“ + + ๅŠŸ่ƒฝ: + 1. ๅญ˜ๅ‚จๅ’Œๆฃ€็ดขๅŒปๅญฆ็Ÿฅ่ฏ† + 2. ๅญ˜ๅ‚จๅ’Œๆฃ€็ดข็—…ไพ‹ๆ•ฐๆฎ + 3. ๅคšๆจกๆ€ๆฃ€็ดข๏ผˆๆ–‡ๆœฌ+ๅฝฑๅƒ๏ผ‰ + + ้…็ฝฎ้€‰้กน (config): + data_path: str - ๅค„็†ๅŽๆ•ฐๆฎ้›†็š„่ทฏๅพ„ + enable_dataset_knowledge: bool - ๆ˜ฏๅฆไปŽๆ•ฐๆฎ้›†ๅŠ ่ฝฝ็Ÿฅ่ฏ† (้ป˜่ฎคTrue) + enable_report_knowledge: bool - ๆ˜ฏๅฆไปŽๅŒปๅญฆๆŠฅๅ‘ŠๅŠ ่ฝฝ็Ÿฅ่ฏ† (้ป˜่ฎคTrue) + enable_case_database: bool - ๆ˜ฏๅฆๅŠ ่ฝฝ็—…ไพ‹ๆ•ฐๆฎๅบ“ (้ป˜่ฎคTrue) + max_reports: int - ๆœ€ๅคง่ฏปๅ–ๆŠฅๅ‘Šๆ•ฐ้‡ (้ป˜่ฎค50) + verbose: bool - ๆ˜ฏๅฆ่พ“ๅ‡บ่ฏฆ็ป†ๆ—ฅๅฟ— (้ป˜่ฎคTrue) + """ + + def __init__(self, config: dict): + """ + ๅˆๅง‹ๅŒ–็Ÿฅ่ฏ†ๅบ“ + + Args: + config: ้…็ฝฎๅญ—ๅ…ธ๏ผŒๆ”ฏๆŒไปฅไธ‹้€‰้กน๏ผš + - data_path: ๆ•ฐๆฎ่ทฏๅพ„ + - enable_dataset_knowledge: ๅฏ็”จๆ•ฐๆฎ้›†็Ÿฅ่ฏ†ๅŠ ่ฝฝ + - enable_report_knowledge: ๅฏ็”จๆŠฅๅ‘Š็Ÿฅ่ฏ†ๅŠ ่ฝฝ + - enable_case_database: ๅฏ็”จ็—…ไพ‹ๆ•ฐๆฎๅบ“ๅŠ ่ฝฝ + - max_reports: ๆœ€ๅคงๆŠฅๅ‘Š่ฏปๅ–ๆ•ฐ้‡ + - verbose: ่ฏฆ็ป†่พ“ๅ‡บ + """ + self.config = config + self.embedder = None + self.vector_db = None + + # ้…็ฝฎ้€‰้กน + self._verbose = config.get("verbose", True) + self._enable_dataset = config.get("enable_dataset_knowledge", True) + self._enable_reports = config.get("enable_report_knowledge", True) + self._enable_cases = config.get("enable_case_database", True) + self._max_reports = config.get("max_reports", 50) + + self._setup_services() + self._load_knowledge() + + def _log(self, message: str): + """่พ“ๅ‡บๆ—ฅๅฟ—ไฟกๆฏ๏ผˆๅฆ‚ๆžœๅฏ็”จ่ฏฆ็ป†ๆจกๅผ๏ผ‰""" + if self._verbose: + print(message) + + def _setup_services(self): + """่ฎพ็ฝฎๆœๅŠก""" + self._log(" Setting up knowledge base services...") + + # ่Žทๅ–embedding้…็ฝฎ + embedding_config = self.config.get("services", {}).get("embedding", {}) + models_config = self.config.get("models", {}) + + embedding_service_config = { + "method": embedding_config.get("method", "hash"), + "model": models_config.get("embedding_model", "BAAI/bge-large-zh-v1.5"), + "batch_size": embedding_config.get("batch_size", 32), + "normalize": embedding_config.get("normalize", True), + "cache_enabled": embedding_config.get("cache_enabled", False), + } + + method = str(embedding_service_config["method"]).lower() + if method != "hash": + raise ValueError( + f"Unsupported embedding method '{method}'. " + "This example currently supports only 'hash'." + ) + + self.embedder = _HashEmbedder(dim=1024) + + # ๅˆๅง‹ๅŒ– SageVDB + # ่Žทๅ–embedding็ปดๅบฆ + dimension = self._embedding_dimension() + # index_type = vector_db_config.get("index_type", "AUTO") + + # TODO: Update to use SageVDB directly after service migration + # self.vector_db = SageVDB(dimension=dimension) + self.vector_db = None # Temporarily disabled pending service migration + + print( + f" โœ“ Embedding initialized (dim={dimension}, method={embedding_service_config['method']})" + ) + # print(f" โœ“ SageVDB initialized (dim={dimension})") + + def _load_knowledge(self): + """ๅŠ ่ฝฝๅŒปๅญฆ็Ÿฅ่ฏ†""" + self._log(" Loading medical knowledge...") + + # ไปŽ้ป˜่ฎค็Ÿฅ่ฏ†ๅผ€ๅง‹ + self.knowledge_base = self._get_default_knowledge() + self.case_database = [] + + # ไปŽๆ•ฐๆฎ้›†ๅŠ ่ฝฝ็Ÿฅ่ฏ† + if self._enable_dataset: + dataset_knowledge = self._load_knowledge_from_dataset() + if dataset_knowledge: + self.knowledge_base.extend(dataset_knowledge) + self._log(f" โœ“ Loaded {len(dataset_knowledge)} knowledge entries from dataset") + + # ไปŽๅŒปๅญฆๆ–‡็Œฎ/ๆŠฅๅ‘ŠๅŠ ่ฝฝ็Ÿฅ่ฏ† + if self._enable_reports: + literature_knowledge = self._load_knowledge_from_reports() + if literature_knowledge: + self.knowledge_base.extend(literature_knowledge) + self._log(f" โœ“ Loaded {len(literature_knowledge)} knowledge entries from reports") + + # ๅŠ ่ฝฝ็—…ไพ‹ๆ•ฐๆฎๅบ“ + if self._enable_cases: + cases = self._load_case_database() + if cases: + self.case_database = cases + self._log(f" โœ“ Loaded {len(cases)} cases from database") + + self._log(f" Total knowledge base size: {len(self.knowledge_base)} entries") + + def _load_knowledge_from_dataset(self) -> list[dict[str, Any]]: + """ไปŽๅค„็†ๅฅฝ็š„ๆ•ฐๆฎ้›†ๅŠ ่ฝฝๅŒปๅญฆ็Ÿฅ่ฏ†""" + knowledge = [] + + # ่Žทๅ–ๆ•ฐๆฎ้›†่ทฏๅพ„้…็ฝฎ + data_path = self.config.get("data_path") + if not data_path: + # ๅฐ่ฏ•้ป˜่ฎค่ทฏๅพ„ + current_file = Path(__file__) + default_path = current_file.parent.parent / "data" / "processed" + if default_path.exists(): + data_path = str(default_path) + else: + return knowledge + + data_dir = Path(data_path) + if not data_dir.exists(): + return knowledge + + # ๅŠ ่ฝฝ็ปŸ่ฎกไฟกๆฏ๏ผŒไปŽไธญๆๅ–็–พ็—…็Ÿฅ่ฏ† + stats_file = data_dir / "stats.json" + if stats_file.exists(): + try: + with open(stats_file, encoding="utf-8") as f: + stats = json.load(f) + + # ไปŽ็–พ็—…ๅˆ†ๅธƒไธญๆๅ–็Ÿฅ่ฏ† + disease_dist = stats.get("disease_distribution", {}) + for disease, count in disease_dist.items(): + if disease and disease != "ๆญฃๅธธ": + knowledge.append( + { + "topic": disease, + "content": f"{disease}ๆ˜ฏๅธธ่ง็š„่…ฐๆคŽ็–พ็—…๏ผŒๅœจๆ•ฐๆฎ้›†ไธญๆœ‰{count}ไธช็›ธๅ…ณ็—…ไพ‹ใ€‚", + "source": "dataset_statistics", + "case_count": count, + } + ) + except Exception as e: + print(f" Warning: Failed to load stats.json: {e}") + + return knowledge + + def _load_knowledge_from_reports(self) -> list[dict[str, Any]]: + """ไปŽๅŒปๅญฆๆŠฅๅ‘Šไธญๆๅ–็Ÿฅ่ฏ†""" + knowledge = [] + + # ่Žทๅ–ๆ•ฐๆฎ้›†่ทฏๅพ„้…็ฝฎ + data_path = self.config.get("data_path") + if not data_path: + # ๅฐ่ฏ•้ป˜่ฎค่ทฏๅพ„ + current_file = Path(__file__) + default_path = current_file.parent.parent / "data" / "processed" + if default_path.exists(): + data_path = str(default_path) + else: + return knowledge + + data_dir = Path(data_path) + reports_dir = data_dir / "reports" + + if not reports_dir.exists(): + return knowledge + + # ่ฏปๅ–ๆ‰€ๆœ‰ๆŠฅๅ‘Šๅนถๆๅ–็Ÿฅ่ฏ† + report_files = list(reports_dir.glob("*.txt")) + + # ็”จไบŽๅญ˜ๅ‚จๆๅ–็š„็‹ฌ็‰น็Ÿฅ่ฏ†็‚น + disease_knowledge = {} + + for report_file in report_files[: self._max_reports]: # ้™ๅˆถ่ฏปๅ–ๆ•ฐ้‡ไปฅ้ฟๅ…่ฟ‡่ฝฝ + try: + with open(report_file, encoding="utf-8") as f: + report_content = f.read() + + # ไปŽๆŠฅๅ‘Šไธญๆๅ–่ฏŠๆ–ญ็ป“่ฎบๅ’Œๆฒป็–—ๅปบ่ฎฎ + lines = report_content.split("\n") + diagnosis = None + treatment = None + findings = None + + for i, line in enumerate(lines): + if "่ฏŠๆ–ญ็ป“่ฎบ:" in line and i + 1 < len(lines): + diagnosis = lines[i + 1].strip() + elif "ๆฒป็–—ๅปบ่ฎฎ:" in line and i + 1 < len(lines): + treatment = lines[i + 1].strip() + elif "ไธป่ฆๅ‘็Žฐ:" in line: + # ๆๅ–ๆŽฅไธ‹ๆฅๅ‡ ่กŒ็š„ๅ‘็Žฐ + findings_lines = [] + for j in range(i + 1, min(i + 5, len(lines))): + if lines[j].strip().startswith("-"): + findings_lines.append(lines[j].strip()) + findings = "\n".join(findings_lines) if findings_lines else None + + # ๅฆ‚ๆžœๆๅ–ๅˆฐๆœ‰ๆ•ˆไฟกๆฏ๏ผŒๆทปๅŠ ๅˆฐ็Ÿฅ่ฏ†ๅบ“ + if diagnosis and diagnosis not in disease_knowledge: + # ไปŽ่ฏŠๆ–ญไธญๆๅ–็–พ็—…ๅ็งฐ + disease_name = diagnosis.split("๏ผŒ")[0] if "๏ผŒ" in diagnosis else diagnosis + + if disease_name and len(disease_name) > 2: + disease_knowledge[disease_name] = { + "topic": disease_name, + "content": diagnosis, + "treatment": treatment if treatment else "่ฏทๅ’จ่ฏขไธ“ไธšๅŒป็”Ÿ", + "findings": findings if findings else "ๅฝฑๅƒๅญฆๆฃ€ๆŸฅๆ˜พ็คบ็›ธๅบ”ๅ˜ๅŒ–", + "source": "medical_reports", + } + + except Exception: + # ๅฟฝ็•ฅๅ•ไธชๆ–‡ไปถ็š„้”™่ฏฏ๏ผŒ็ปง็ปญๅค„็†ๅ…ถไป–ๆ–‡ไปถ + continue + + # ๅฐ†ๆๅ–็š„็Ÿฅ่ฏ†่ฝฌๆขไธบๅˆ—่กจ + knowledge = list(disease_knowledge.values()) + + return knowledge + + def _load_case_database(self) -> list[dict[str, Any]]: + """ไปŽๆ•ฐๆฎ้›†ๅŠ ่ฝฝ็—…ไพ‹ๆ•ฐๆฎๅบ“""" + cases = [] + + # ่Žทๅ–ๆ•ฐๆฎ้›†่ทฏๅพ„้…็ฝฎ + data_path = self.config.get("data_path") + if not data_path: + # ๅฐ่ฏ•้ป˜่ฎค่ทฏๅพ„ + current_file = Path(__file__) + default_path = current_file.parent.parent / "data" / "processed" + if default_path.exists(): + data_path = str(default_path) + else: + return cases + + data_dir = Path(data_path) + + # ๅฐ่ฏ•ๅŠ ่ฝฝๆ‰€ๆœ‰็—…ไพ‹็ดขๅผ• + all_cases_file = data_dir / "all_cases.json" + if all_cases_file.exists(): + try: + with open(all_cases_file, encoding="utf-8") as f: + cases_data = json.load(f) + + # ่ฝฌๆขไธบ็ปŸไธ€ๆ ผๅผ + for case in cases_data: + cases.append( + { + "case_id": case.get("case_id", ""), + "age": case.get("age", 0), + "gender": case.get("gender", "unknown"), + "diagnosis": case.get("disease", ""), + "severity": case.get("severity", ""), + "image_path": case.get("image_path", ""), + "report_path": case.get("report_path", ""), + } + ) + + except Exception as e: + print(f" Warning: Failed to load case database: {e}") + + return cases + + def _get_default_knowledge(self) -> list[dict[str, str]]: + """่Žทๅ–้ป˜่ฎคๅŒปๅญฆ็Ÿฅ่ฏ†""" + return [ + { + "topic": "่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + "content": """ + ่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡ๆ˜ฏๆŒ‡ๆคŽ้—ด็›˜็š„็บค็ปด็Žฏ็ ด่ฃ‚๏ผŒ้ซ“ๆ ธ็ป„็ป‡ไปŽ็ ด่ฃ‚ๅค„็ชๅ‡บไบŽๅŽๆ–นๆˆ–ๆคŽ็ฎกๅ†…๏ผŒ + ๅฏผ่‡ด็›ธ้‚ป่„Š็ฅž็ปๆ น้ญๅ—ๅˆบๆฟ€ๆˆ–ๅŽ‹่ฟซ๏ผŒไปŽ่€Œไบง็”Ÿ่…ฐ้ƒจ็–ผ็—›ใ€ไธ‹่‚ข้บปๆœจ็–ผ็—›็ญ‰ไธ€็ณปๅˆ—ไธดๅบŠ็—‡็Šถใ€‚ + ๅฅฝๅ‘้ƒจไฝ๏ผšL4/L5ใ€L5/S1ใ€‚ + """, + "diagnosis_criteria": "MRI T2ๅŠ ๆƒๅƒๆ˜พ็คบๆคŽ้—ด็›˜ๅŽ็ช๏ผŒ็กฌ่†œๅ›Šๅ—ๅŽ‹ๅ˜ๅฝข", + "treatment": "ไฟๅฎˆๆฒป็–—ๅŒ…ๆ‹ฌๅงๅบŠไผ‘ๆฏใ€็‰ฉ็†ๆฒป็–—ใ€่ฏ็‰ฉๆฒป็–—๏ผ›ไธฅ้‡่€…ๅฏ่€ƒ่™‘ๆ‰‹ๆœฏ", + }, + { + "topic": "่…ฐๆคŽ้€€่กŒๆ€งๅ˜", + "content": """ + ่…ฐๆคŽ้€€่กŒๆ€งๅ˜ๆ˜ฏๆŒ‡้šๅนด้พ„ๅขž้•ฟ๏ผŒ่…ฐๆคŽ้—ด็›˜ใ€ๆคŽไฝ“ๅŠๅฐๅ…ณ่Š‚ๅ‘็”Ÿ็š„้€€่กŒๆ€งๆ”นๅ˜ใ€‚ + ไธป่ฆ่กจ็ŽฐไธบๆคŽ้—ด็›˜้ซ˜ๅบฆ้™ไฝŽใ€ไฟกๅทๅ‡ไฝŽใ€้ชจ่ดจๅขž็”Ÿ็ญ‰ใ€‚ + """, + "diagnosis_criteria": "MRIๆ˜พ็คบๆคŽ้—ด็›˜ไฟกๅทๅ‡ไฝŽใ€้ซ˜ๅบฆ้™ไฝŽใ€ๆคŽไฝ“่พน็ผ˜้ชจ่ต˜ๅฝขๆˆ", + "treatment": "ไปฅไฟๅฎˆๆฒป็–—ไธบไธป๏ผŒๅŠ ๅผบ่…ฐ่ƒŒ่‚Œ้”ป็‚ผ๏ผŒ้ฟๅ…ไน…ๅไน…็ซ™", + }, + { + "topic": "ๆคŽ็ฎก็‹ญ็ช„", + "content": """ + ่…ฐๆคŽ็ฎก็‹ญ็ช„็—‡ๆ˜ฏๆŒ‡ๅ› ๅ„็งๅŽŸๅ› ๅฏผ่‡ดๆคŽ็ฎกๅฎน็งฏๅ‡ๅฐ๏ผŒๅŽ‹่ฟซ็กฌ่†œๅ›Šใ€้ฉฌๅฐพ็ฅž็ปๆˆ–็ฅž็ปๆ น๏ผŒ + ๅผ•่ตท็›ธๅบ”็ฅž็ปๅŠŸ่ƒฝ้šœ็ข็š„ไธ€็ป„็ปผๅˆๅพใ€‚ + """, + "diagnosis_criteria": "MRIๆ˜พ็คบๆคŽ็ฎก็Ÿข็Šถๅพ„<12mm๏ผŒ็กฌ่†œๅ›Šๆ˜Žๆ˜พๅ—ๅŽ‹", + "treatment": "่ฝปๅบฆๅฏไฟๅฎˆๆฒป็–—๏ผŒ้‡ๅบฆ้œ€ๆ‰‹ๆœฏๅ‡ๅŽ‹", + }, + ] + + def retrieve_similar_cases( + self, query: str, image_features: dict[str, Any], top_k: int = 5 + ) -> list[dict[str, Any]]: + """ + ๆฃ€็ดข็›ธไผผ็—…ไพ‹ + + Args: + query: ๆŸฅ่ฏขๆ–‡ๆœฌ + image_features: ๅฝฑๅƒ็‰นๅพ + top_k: ่ฟ”ๅ›žๆ•ฐ้‡ + + Returns: + ็›ธไผผ็—…ไพ‹ๅˆ—่กจ + """ + # ๅฆ‚ๆžœๅ‘้‡ๆ•ฐๆฎๅบ“ๆœชๅฏ็”จๆˆ–ไธบ็ฉบ๏ผŒไฝฟ็”จๅ…ณ้”ฎ่ฏๅŒน้…ๆˆ–ๆจกๆ‹Ÿ็—…ไพ‹ + if self.vector_db is None or self.vector_db.stats()["size"] == 0: + # ๅฆ‚ๆžœๆœ‰ๅŠ ่ฝฝ็š„็—…ไพ‹ๆ•ฐๆฎๅบ“๏ผŒไฝฟ็”จๅฎƒ๏ผ›ๅฆๅˆ™่ฟ”ๅ›žๆจกๆ‹Ÿ็—…ไพ‹ + if self.case_database: + # ็ฎ€ๅ•็š„ๅ…ณ้”ฎ่ฏๅŒน้…๏ผˆๅฎž้™…ๅบ”่ฏฅไฝฟ็”จๅ‘้‡ๆฃ€็ดข๏ผ‰ + matched_cases = [] + query_keywords = query.lower().split() + + for case in self.case_database: + # ่ฎก็ฎ—ๅŒน้…ๅˆ†ๆ•ฐ + score = 0.0 + diagnosis = case.get("diagnosis", "").lower() + + for keyword in query_keywords: + if keyword in diagnosis: + score += 1.0 + + if score > 0: + matched_cases.append( + { + "case_id": case.get("case_id", ""), + "age": case.get("age", 0), + "gender": case.get("gender", "unknown"), + "diagnosis": case.get("diagnosis", ""), + "severity": case.get("severity", ""), + "similarity_score": min(score / len(query_keywords), 1.0), + } + ) + + # ๆŒ‰็›ธไผผๅบฆๆŽ’ๅบ + matched_cases.sort(key=lambda x: x["similarity_score"], reverse=True) + + # ๅฆ‚ๆžœๆ‰พๅˆฐๅŒน้…็š„็—…ไพ‹๏ผŒ่ฟ”ๅ›žๅฎƒไปฌ + if matched_cases: + return matched_cases[:top_k] + + # ๆฒกๆœ‰ๅŠ ่ฝฝ็š„ๆ•ฐๆฎๆˆ–ๆฒกๆœ‰ๅŒน้…๏ผŒ่ฟ”ๅ›žๆจกๆ‹Ÿ็—…ไพ‹ + return self._get_mock_cases()[:top_k] + + # ไฝฟ็”จ embedding ๅฎขๆˆท็ซฏ็”ŸๆˆๆŸฅ่ฏขๅ‘้‡ + query_vector = self._embed_one(query) + + # ไฝฟ็”จ SageDB ่ฟ›่กŒๅ‘้‡ๆฃ€็ดข + search_results = self.vector_db.search(query_vector, k=top_k, include_metadata=True) + + # ่ฝฌๆขไธบ็—…ไพ‹ๆ ผๅผ + cases = [] + for res in search_results: + metadata = res.get("metadata", {}) + cases.append( + { + "case_id": metadata.get("case_id", f"CASE_{res['id']:03d}"), + "age": int(metadata.get("age", 0)) if metadata.get("age") else 0, + "gender": metadata.get("gender", "unknown"), + "diagnosis": metadata.get("diagnosis", ""), + "symptoms": metadata.get("symptoms", ""), + "treatment": metadata.get("treatment", ""), + "outcome": metadata.get("outcome", ""), + "similarity_score": float(res["score"]), + } + ) + + return cases + + def retrieve_knowledge(self, query: str, top_k: int = 3) -> list[dict[str, Any]]: + """ + ๆฃ€็ดขๅŒปๅญฆ็Ÿฅ่ฏ† + + Args: + query: ๆŸฅ่ฏขๆ–‡ๆœฌ + top_k: ่ฟ”ๅ›žๆ•ฐ้‡ + + Returns: + ๅŒปๅญฆ็Ÿฅ่ฏ†ๅˆ—่กจ + """ + # ไฝฟ็”จ็ฎ€ๅ•็š„ๅ…ณ้”ฎ่ฏๅŒน้…๏ผˆๅฏไปฅๅŽ็ปญๆ”น่ฟ›ไธบๅ‘้‡ๆฃ€็ดข๏ผ‰ + results = [] + + for knowledge in self.knowledge_base: + topic = knowledge.get("topic", "") + content = knowledge.get("content", "") + + # Check if query contains topic or topic contains query (for Chinese text) + # Also check if query matches content + if ( + topic in query + or query in topic + or any(char in topic for char in query if len(char.strip()) > 0) + or topic in content + or query in content + ): + results.append(knowledge) + + # ๅฆ‚ๆžœๆฒกๆœ‰ๅŒน้…็ป“ๆžœ๏ผŒไฝฟ็”จๅ‘้‡็›ธไผผๅบฆๆฃ€็ดข + if not results and self.embedder: + # ไธบ็Ÿฅ่ฏ†ๅบ“ๆก็›ฎ็”Ÿๆˆembeddingๅนถๆฃ€็ดข + knowledge_texts = [k["topic"] + " " + k["content"] for k in self.knowledge_base] + knowledge_embeddings = self._embed_many(knowledge_texts) + + # ไธบๆŸฅ่ฏข็”Ÿๆˆembedding + query_vec = self._embed_one(query) + + # ่ฎก็ฎ—็›ธไผผๅบฆๅนถๆŽ’ๅบ + import numpy as np + + similarities = [] + for i, emb_vec in enumerate(knowledge_embeddings): + similarity = float(np.dot(query_vec, emb_vec)) + similarities.append((i, similarity)) + + # ๆŒ‰็›ธไผผๅบฆๆŽ’ๅบ + similarities.sort(key=lambda x: x[1], reverse=True) + + # ่ฟ”ๅ›žtop_kไธชๆœ€็›ธไผผ็š„็Ÿฅ่ฏ† + for idx, _ in similarities[:top_k]: + results.append(self.knowledge_base[idx]) + + return results[:top_k] + + def _get_mock_cases(self) -> list[dict[str, Any]]: + """่Žทๅ–ๆจกๆ‹Ÿ็—…ไพ‹""" + return [ + { + "case_id": "CASE_001", + "age": 48, + "gender": "male", + "diagnosis": "L4/L5ๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + "symptoms": "ไธ‹่ƒŒ้ƒจ็–ผ็—›๏ผŒๅทฆ่…ฟ้บปๆœจ", + "treatment": "ไฟๅฎˆๆฒป็–—3ไธชๆœˆๅŽ็—‡็Šถ็ผ“่งฃ", + "outcome": "่‰ฏๅฅฝ", + "similarity_score": 0.92, + }, + { + "case_id": "CASE_002", + "age": 52, + "gender": "male", + "diagnosis": "L5/S1ๆคŽ้—ด็›˜็ชๅ‡บ็—‡ไผดๆคŽ็ฎก็‹ญ็ช„", + "symptoms": "่…ฐ็—›ไผดๅŒไธ‹่‚ข้บปๆœจ", + "treatment": "ๆ‰‹ๆœฏๆฒป็–—๏ผˆๆคŽ้—ด็›˜ๅˆ‡้™ค+ๆคŽ็ฎกๅ‡ๅŽ‹๏ผ‰", + "outcome": "็—‡็Šถๆ˜Žๆ˜พๆ”นๅ–„", + "similarity_score": 0.88, + }, + { + "case_id": "CASE_003", + "age": 43, + "gender": "female", + "diagnosis": "L4/L5ๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + "symptoms": "ๅณไธ‹่‚ขๆ”พๅฐ„็—›", + "treatment": "็‰ฉ็†ๆฒป็–—+่ฏ็‰ฉๆฒป็–—", + "outcome": "้ƒจๅˆ†็ผ“่งฃ", + "similarity_score": 0.85, + }, + { + "case_id": "CASE_004", + "age": 60, + "gender": "male", + "diagnosis": "่…ฐๆคŽ้€€่กŒๆ€งๅ˜๏ผŒL3/L4ใ€L4/L5ๆคŽ้—ด็›˜็ชๅ‡บ", + "symptoms": "ๆ…ขๆ€ง่…ฐ็—›", + "treatment": "้•ฟๆœŸๅบทๅค่ฎญ็ปƒ", + "outcome": "็จณๅฎš", + "similarity_score": 0.82, + }, + { + "case_id": "CASE_005", + "age": 38, + "gender": "female", + "diagnosis": "L5/S1ๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + "symptoms": "ๆ€ฅๆ€ง่…ฐ็—›๏ผŒๅทฆ่…ฟ้บปๆœจ", + "treatment": "ๅงๅบŠไผ‘ๆฏ+ๆญข็—›่ฏ", + "outcome": "2ๅ‘จๅŽๅฅฝ่ฝฌ", + "similarity_score": 0.80, + }, + ] + + def add_case(self, case_data: dict[str, Any]): + """ๆทปๅŠ ๆ–ฐ็—…ไพ‹ๅˆฐ็Ÿฅ่ฏ†ๅบ“""" + # ๆž„ๅปบ็—…ไพ‹ๆ–‡ๆœฌๆ่ฟฐ็”จไบŽembedding + case_text = f"{case_data.get('diagnosis', '')} {case_data.get('symptoms', '')}" + + # ็”Ÿๆˆๆ–‡ๆœฌembedding + text_vector = self._embed_one(case_text) + + # ๅฏ้€‰๏ผš่žๅˆๅฝฑๅƒ็‰นๅพ๏ผˆ่‹ฅๅทฒ็”ฑๅฝฑๅƒๅˆ†ๆžๅ™จๆไพ›๏ผ‰ + case_vector = text_vector + image_vector = case_data.get("image_embedding") + if image_vector is None: + image_features = case_data.get("image_features") + if isinstance(image_features, dict): + image_vector = image_features.get("image_embedding") + + if image_vector is not None: + try: + image_vector_list = [float(value) for value in image_vector] + if len(image_vector_list) == len(text_vector): + fused = [ + 0.5 * text_val + 0.5 * image_val + for text_val, image_val in zip(text_vector, image_vector_list, strict=True) + ] + norm = sum(value * value for value in fused) ** 0.5 + if norm > 0: + case_vector = [value / norm for value in fused] + except (TypeError, ValueError): + pass + + # ๅ‡†ๅค‡metadata + metadata = { + "case_id": str(case_data.get("case_id", "")), + "age": str(case_data.get("age", "")), + "gender": str(case_data.get("gender", "")), + "diagnosis": str(case_data.get("diagnosis", "")), + "symptoms": str(case_data.get("symptoms", "")), + "treatment": str(case_data.get("treatment", "")), + "outcome": str(case_data.get("outcome", "")), + "has_image_embedding": str(image_vector is not None).lower(), + } + + # ๅญ˜ๅ…ฅๅ‘้‡ๆ•ฐๆฎๅบ“๏ผˆ่‹ฅๅฏ็”จ๏ผ‰ + if self.vector_db is not None: + self.vector_db.add(case_vector, metadata) + + # ๅŒๆ—ถๆทปๅŠ ๅˆฐๆœฌๅœฐ็ผ“ๅญ˜ + self.case_database.append(case_data) + + def update_knowledge(self, knowledge_data: dict[str, Any]) -> dict[str, str]: + """ + ๆ›ดๆ–ฐๅŒปๅญฆ็Ÿฅ่ฏ† + + Args: + knowledge_data: ็Ÿฅ่ฏ†ๆ•ฐๆฎๅญ—ๅ…ธ๏ผŒๅฟ…้กปๅŒ…ๅซ 'topic' ๅญ—ๆฎต + + Returns: + ๆ“ไฝœ็ป“ๆžœๅญ—ๅ…ธ๏ผŒๅŒ…ๅซ 'action' (added/updated) ๅ’Œ 'topic' ๅญ—ๆฎต + + Raises: + ValueError: ๅฆ‚ๆžœ knowledge_data ็ผบๅฐ‘ๅฟ…้œ€็š„ 'topic' ๅญ—ๆฎต + """ + # ้ชŒ่ฏๅฟ…้œ€ๅญ—ๆฎต + if not knowledge_data or "topic" not in knowledge_data: + raise ValueError("knowledge_data must contain 'topic' field") + + topic = knowledge_data["topic"] + + # ๆฃ€ๆŸฅๆ˜ฏๅฆๅทฒๅญ˜ๅœจ็›ธๅŒไธป้ข˜็š„็Ÿฅ่ฏ† + existing_index = None + for i, knowledge in enumerate(self.knowledge_base): + if knowledge.get("topic") == topic: + existing_index = i + break + + if existing_index is not None: + # ๆ›ดๆ–ฐ็Žฐๆœ‰็Ÿฅ่ฏ† + self.knowledge_base[existing_index] = knowledge_data + return {"action": "updated", "topic": topic} + else: + # ๆทปๅŠ ๆ–ฐ็Ÿฅ่ฏ† + self.knowledge_base.append(knowledge_data) + return {"action": "added", "topic": topic} + + def cleanup(self): + """ๆธ…็†่ต„ๆบ""" + if self.embedder and hasattr(self.embedder, "cleanup"): + self.embedder.cleanup() + + def _embed_one(self, text: str) -> list[float]: + if self.embedder is None: + raise RuntimeError("Embedding component is not initialized") + return self.embedder.embed(text) + + def _embed_many(self, texts: list[str]) -> list[list[float]]: + if self.embedder is None: + raise RuntimeError("Embedding component is not initialized") + if hasattr(self.embedder, "embed_batch"): + return self.embedder.embed_batch(texts) + return [self.embedder.embed(text) for text in texts] + + def _embedding_dimension(self) -> int: + if self.embedder is None: + raise RuntimeError("Embedding component is not initialized") + if hasattr(self.embedder, "get_dim"): + return int(self.embedder.get_dim()) + sample = self._embed_one("dim_probe") + return len(sample) + + def __del__(self): + """ๆžๆž„ๆ—ถๆธ…็†่ต„ๆบ""" + self.cleanup() + + +if __name__ == "__main__": + # ๆต‹่ฏ• + config = {"services": {"vector_db": {"collection_name": "lumbar_cases", "top_k": 5}}} + + kb = MedicalKnowledgeBase(config) + + print("=" * 80) + print("ๆต‹่ฏ•็Ÿฅ่ฏ†ๅบ“ๅŠŸ่ƒฝ") + print("=" * 80) + + # ๆต‹่ฏ•ๆฃ€็ดข + print(f"\n1. ๅˆๅง‹็Ÿฅ่ฏ†ๅบ“ๅŒ…ๅซ {len(kb.knowledge_base)} ไธช็Ÿฅ่ฏ†ๆก็›ฎ") + print(f" ไธป้ข˜: {[k['topic'] for k in kb.knowledge_base]}") + + # ๆต‹่ฏ•ๆทปๅŠ ๆ–ฐ็Ÿฅ่ฏ† + print("\n2. ๆต‹่ฏ•ๆทปๅŠ ๆ–ฐ็Ÿฅ่ฏ†") + new_knowledge = { + "topic": "ๆคŽไฝ“ๅŽ‹็ผฉๆ€ง้ชจๆŠ˜", + "content": "ๆคŽไฝ“ๅŽ‹็ผฉๆ€ง้ชจๆŠ˜ๆ˜ฏๆŒ‡ๆคŽไฝ“ๅœจๅค–ๅŠ›ไฝœ็”จไธ‹ๅ‘็”ŸๅŽ‹็ผฉๆ€งๅ˜ๅฝข", + "diagnosis_criteria": "X็บฟๆˆ–CTๆ˜พ็คบๆคŽไฝ“้ซ˜ๅบฆ้™ไฝŽ่ถ…่ฟ‡20%", + "treatment": "ๆ€ฅๆ€งๆœŸๅงๅบŠไผ‘ๆฏ๏ผŒๅฟ…่ฆๆ—ถ่กŒๆคŽไฝ“ๆˆๅฝขๆœฏ", + } + result = kb.update_knowledge(new_knowledge) + print(f" ็ป“ๆžœ: {result['action']} - {result['topic']}") + print(f" ็Ÿฅ่ฏ†ๅบ“็Žฐๆœ‰ {len(kb.knowledge_base)} ไธชๆก็›ฎ") + + # ๆต‹่ฏ•ๆ›ดๆ–ฐ็Žฐๆœ‰็Ÿฅ่ฏ† + print("\n3. ๆต‹่ฏ•ๆ›ดๆ–ฐ็Žฐๆœ‰็Ÿฅ่ฏ†") + updated_knowledge = { + "topic": "่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + "content": "ๆ›ดๆ–ฐๅŽ็š„ๅ†…ๅฎน๏ผš่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡ๆ˜ฏๆœ€ๅธธ่ง็š„่…ฐๆคŽ็–พ็—…ไน‹ไธ€", + "diagnosis_criteria": "ๆ›ดๆ–ฐๅŽ็š„่ฏŠๆ–ญๆ ‡ๅ‡†", + "treatment": "ๆ›ดๆ–ฐๅŽ็š„ๆฒป็–—ๆ–นๆกˆ", + "additional_info": "ๆ–ฐๅขžๅญ—ๆฎต๏ผš่ฟ™ๆ˜ฏ่กฅๅ……ไฟกๆฏ", + } + result = kb.update_knowledge(updated_knowledge) + print(f" ็ป“ๆžœ: {result['action']} - {result['topic']}") + print(f" ็Ÿฅ่ฏ†ๅบ“ไปๆœ‰ {len(kb.knowledge_base)} ไธชๆก็›ฎ (ๆœชๅขžๅŠ )") + + # ้ชŒ่ฏๆ›ดๆ–ฐๆ˜ฏๅฆๆˆๅŠŸ + disc_knowledge = [k for k in kb.knowledge_base if k["topic"] == "่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡"] + print(f" '่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡' ๆก็›ฎๆ•ฐ: {len(disc_knowledge)}") + if disc_knowledge: + print(f" ๆ›ดๆ–ฐๅŽ็š„ๅ†…ๅฎน้ข„่งˆ: {disc_knowledge[0]['content'][:50]}...") + + # ๆต‹่ฏ•็›ธไผผ็—…ไพ‹ๆฃ€็ดข + print("\n4. ๆต‹่ฏ•็›ธไผผ็—…ไพ‹ๆฃ€็ดข") + cases = kb.retrieve_similar_cases(query="่…ฐ็—›ไผดไธ‹่‚ข้บปๆœจ", image_features={}, top_k=3) + print(f" ๆฃ€็ดขๅˆฐ {len(cases)} ไธช็›ธไผผ็—…ไพ‹:") + for case in cases: + print(f" - {case['case_id']}: {case['diagnosis']} (็›ธไผผๅบฆ: {case['similarity_score']})") + + print("\n" + "=" * 80) diff --git a/apps/src/sage/apps/py.typed b/apps/src/sage/apps/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/apps/src/sage/apps/smart_home/README.md b/apps/src/sage/apps/smart_home/README.md new file mode 100644 index 0000000..f8da23c --- /dev/null +++ b/apps/src/sage/apps/smart_home/README.md @@ -0,0 +1,232 @@ +# Smart Home System + +A distributed IoT-based smart home automation system built on SAGE framework demonstrating +interconnectivity and coordination capabilities across multiple devices. + +## Features + +- **IoT Device Network**: Simulates various smart home devices (robots, sensors, appliances) +- **Automated Workflows**: Coordinates complex multi-device workflows using SAGE operators +- **Environment Monitoring**: Real-time sensor data collection and response +- **Event-Driven Architecture**: Devices communicate through SAGE stream processing +- **Device Coordination**: Manages dependencies and sequencing with MapFunction chains + +## Quick Start + +```python +from sage.apps.smart_home import run_smart_home_demo + +# Run single laundry automation cycle +run_smart_home_demo() + +# Run multiple cycles +run_smart_home_demo(num_cycles=3) + +# Verbose mode with event logging +run_smart_home_demo(num_cycles=2, verbose=True) +``` + +## Command Line Usage + +```bash +# Run default (1 cycle) +python -m sage.apps.smart_home.pipeline + +# Run multiple cycles +python -m sage.apps.smart_home.pipeline --cycles 3 + +# Verbose mode +python -m sage.apps.smart_home.pipeline --verbose +``` + +## Pipeline Architecture + +The system uses SAGE stream processing operators: + +``` +LaundryWorkflowSource (BatchFunction) + โ†“ +DeviceExecutor (MapFunction) + โ†“ +EnvironmentMonitor (MapFunction) + โ†“ +WorkflowProgressSink (SinkFunction) + โ†“ +EventLogSink (SinkFunction) [optional] +``` + +### Operators + +- **LaundryWorkflowSource**: Generates workflow tasks (check env โ†’ collect โ†’ wash โ†’ transfer โ†’ dry โ†’ + store) +- **DeviceExecutor**: Executes tasks on simulated IoT devices (robot, washer, dryer, sensors) +- **EnvironmentMonitor**: Monitors and validates environmental conditions +- **WorkflowProgressSink**: Tracks and reports workflow progress +- **EventLogSink**: Logs all device events for auditing + +## Devices + +### Robot (๐Ÿค–) + +- **Purpose**: Move items between locations +- **Commands**: `move_laundry` +- **Events**: `task_completed` + +### Washer (๐Ÿงบ) + +- **Purpose**: Wash laundry +- **Commands**: `start_wash` +- **Events**: `wash_completed` + +### Dryer (๐Ÿ’จ) + +- **Purpose**: Dry laundry +- **Commands**: `start_dry` +- **Events**: `dry_completed` + +### Humidity Sensor (๐Ÿ“Š) + +- **Purpose**: Monitor humidity levels +- **Commands**: `read` +- **Events**: `reading` +- **Range**: 30-80% + +### Motion Sensor (๐Ÿ‘๏ธ) + +- **Purpose**: Detect motion +- **Commands**: `detect` +- **Events**: `motion_detected`, `no_motion` + +## Automated Workflows + +### Laundry Automation + +Complete end-to-end laundry automation: + +1. **Environment Check**: Monitor humidity and conditions +1. **Collection**: Robot picks up laundry from basket +1. **Washing**: Washer runs complete wash cycle +1. **Transfer**: Robot moves laundry to dryer +1. **Drying**: Dryer runs complete dry cycle +1. **Storage**: Robot moves laundry to drying rack + +```python +home = SmartHomeSystem() +home.laundry_automation_workflow() +``` + +### Custom Workflows + +Create your own automation: + +```python +# Send custom commands +home.send_command("robot_001", "move_laundry", {"from": "basket", "to": "washer"}) + +home.send_command("washer_001", "start_wash") +``` + +## Architecture + +### Event-Driven Communication + +Devices communicate through events: + +``` +Sensor Reading โ†’ Event โ†’ Automation Rule โ†’ Device Command โ†’ Action +``` + +### Device Coordination + +The system coordinates multiple devices: + +``` +Robot โ†’ Washer โ†’ Robot โ†’ Dryer โ†’ Robot + | | | | | + v v v v v +Event โ†’ Event โ†’ Event โ†’ Event โ†’ Event +``` + +## Example Output + +``` +====================================================================== +Smart Home Laundry Automation Workflow +====================================================================== + +[Step 1] Checking environmental conditions... + ๐Ÿ“Š Humidity: 45.3% + +[Step 2] Robot collecting laundry... + ๐Ÿค– Robot robot_001: Moving laundry from basket to washer + +[Step 3] Washing laundry... + ๐Ÿงบ Washer washer_001: Starting wash cycle + โœ“ Washer washer_001: Wash cycle completed + +[Step 4] Robot moving laundry to dryer... + ๐Ÿค– Robot robot_001: Moving laundry from washer to dryer + +[Step 5] Drying laundry... + ๐Ÿ’จ Dryer dryer_001: Starting dry cycle + โœ“ Dryer dryer_001: Dry cycle completed + +[Step 6] Robot moving laundry to drying rack... + ๐Ÿค– Robot robot_001: Moving laundry from dryer to drying_rack + +====================================================================== +โœ“ Laundry automation workflow completed successfully! +====================================================================== +``` + +## Use Cases + +- **Home Automation**: Automated household tasks +- **IoT Coordination**: Multi-device workflows +- **Smart Building**: Building management systems +- **Industrial IoT**: Factory automation +- **Energy Management**: Optimize resource usage + +## Testing + +Run the example script: + +```bash +python examples/apps/run_smart_home.py +``` + +Run with specific workflow: + +```bash +python examples/apps/run_smart_home.py --workflow laundry +python examples/apps/run_smart_home.py --workflow monitoring +``` + +## Configuration + +Devices can be configured with custom parameters: + +```python +# Custom cycle times +washer = Washer("washer_001") +washer.cycle_time = 5 # seconds + +# Custom thresholds +humidity_sensor = HumiditySensor("humid_001") +``` + +## Dependencies + +- Python 3.8+ +- Standard library only +- Optional: SAGE framework for distributed processing + +## Future Enhancements + +- Real IoT device integration (MQTT, CoAP) +- Machine learning for predictive automation +- Voice control integration +- Mobile app interface +- Energy optimization algorithms +- Multi-home coordination +- Cloud integration for remote control diff --git a/apps/src/sage/apps/smart_home/__init__.py b/apps/src/sage/apps/smart_home/__init__.py new file mode 100644 index 0000000..669209b --- /dev/null +++ b/apps/src/sage/apps/smart_home/__init__.py @@ -0,0 +1,20 @@ +""" +Smart Home System + +A distributed IoT-based smart home automation system demonstrating SAGE's +interconnectivity capabilities. Manages coordinated workflows between devices +like robots, sensors, washers, and dryers. + +Built on SAGE framework using stream processing operators. + +Features: +- IoT device network simulation +- Automated workflows (e.g., laundry automation) +- Environment monitoring and response +- Device coordination and communication +- Event-driven automation +""" + +from .pipeline import run_smart_home_demo + +__all__ = ["run_smart_home_demo"] diff --git a/apps/src/sage/apps/smart_home/operators.py b/apps/src/sage/apps/smart_home/operators.py new file mode 100644 index 0000000..97a9a0b --- /dev/null +++ b/apps/src/sage/apps/smart_home/operators.py @@ -0,0 +1,263 @@ +""" +SAGE Operators for Smart Home System + +Implements IoT device operators for smart home automation. +""" + +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Any + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + + +class DeviceType(Enum): + """IoT device types.""" + + ROBOT = "robot" + WASHER = "washer" + DRYER = "dryer" + HUMIDITY_SENSOR = "humidity_sensor" + MOTION_SENSOR = "motion_sensor" + + +class TaskStatus(Enum): + """Task execution status.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass +class DeviceEvent: + """Event emitted by a device.""" + + event_id: int + device_id: str + device_type: DeviceType + event_type: str + timestamp: str + data: dict[str, Any] + + +class LaundryWorkflowSource(BatchFunction): + """ + BatchFunction source that generates laundry workflow tasks. + + Emits a sequence of tasks for the automated laundry workflow. + """ + + def __init__(self, num_cycles: int = 1, **kwargs) -> None: + super().__init__(**kwargs) + self.num_cycles = num_cycles + self.current_cycle = 0 + self.step = 0 + + # Define workflow steps + self.steps = [ + { + "task": "check_environment", + "device": "humid_sensor_001", + "description": "Check humidity and environment", + }, + { + "task": "collect_laundry", + "device": "robot_001", + "description": "Robot collects laundry from basket", + "params": {"from": "basket", "to": "washer"}, + }, + { + "task": "wash", + "device": "washer_001", + "description": "Wash laundry", + "duration": 2.0, + }, + { + "task": "move_to_dryer", + "device": "robot_001", + "description": "Robot moves laundry to dryer", + "params": {"from": "washer", "to": "dryer"}, + }, + { + "task": "dry", + "device": "dryer_001", + "description": "Dry laundry", + "duration": 1.5, + }, + { + "task": "move_to_rack", + "device": "robot_001", + "description": "Robot moves laundry to drying rack", + "params": {"from": "dryer", "to": "drying_rack"}, + }, + ] + + def execute(self) -> dict[str, Any] | None: + """Generate next workflow task.""" + if self.current_cycle >= self.num_cycles: + return None + + if self.step >= len(self.steps): + self.current_cycle += 1 + self.step = 0 + if self.current_cycle >= self.num_cycles: + return None + + task = self.steps[self.step].copy() + task["cycle"] = self.current_cycle + 1 + task["step"] = self.step + 1 + task["total_steps"] = len(self.steps) + + self.step += 1 + return task + + +class DeviceExecutor(MapFunction): + """ + MapFunction that simulates device task execution. + """ + + # Humidity sensor constants + MIN_HUMIDITY = 30.0 + MAX_HUMIDITY = 80.0 + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.event_counter = 0 + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Execute device task.""" + device_id = data.get("device", "unknown") + task = data.get("task", "unknown") + description = data.get("description", "") + duration = data.get("duration", 0.5) + + # Log task start + icon = { + "robot_001": "๐Ÿค–", + "washer_001": "๐Ÿงบ", + "dryer_001": "๐Ÿ’จ", + "humid_sensor_001": "๐Ÿ“Š", + }.get(device_id, "๐Ÿ“ฑ") + + self.logger.info(f"{icon} {device_id}: {description}") + + # Simulate task execution + time.sleep(duration) + + # Generate result + self.event_counter += 1 + result = data.copy() + result["status"] = TaskStatus.COMPLETED.value + result["completed_at"] = datetime.now().isoformat() + result["event_id"] = self.event_counter + + # Add device-specific results + if "humid_sensor" in device_id: + result["humidity"] = random.uniform(self.MIN_HUMIDITY, self.MAX_HUMIDITY) + elif task in ["wash", "dry"]: + result["success"] = True + + return result + + +class EnvironmentMonitor(MapFunction): + """ + MapFunction that monitors environmental conditions. + """ + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + """Monitor and log environmental conditions.""" + if data.get("task") == "check_environment": + humidity = data.get("humidity", 0) + self.logger.info(f" ๐Ÿ“Š Current humidity: {humidity:.1f}%") + + # Check if conditions are suitable + if 40 <= humidity <= 70: + data["environment_status"] = "optimal" + elif humidity < 40: + data["environment_status"] = "too_dry" + else: + data["environment_status"] = "too_humid" + + return data + + +class WorkflowProgressSink(SinkFunction): + """ + SinkFunction that tracks and displays workflow progress. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.tasks_completed = 0 + self.start_time = time.time() + self.total_steps = 0 + + def execute(self, data: dict[str, Any]) -> None: + """Track task completion.""" + self.tasks_completed += 1 + step = data.get("step", 0) + total = data.get("total_steps", 0) + cycle = data.get("cycle", 1) + + if total > 0: + self.total_steps = total + + # Log progress + if step == total: + self.logger.info(f"โœ“ Cycle {cycle} completed ({step}/{total} steps)") + + def close(self) -> None: + """Display final summary.""" + elapsed = time.time() - self.start_time + + print("\n" + "=" * 70) + print("โœ… Smart Home Workflow Summary") + print("=" * 70) + print(f" Total tasks completed: {self.tasks_completed}") + print(f" Total time: {elapsed:.2f}s") + print(f" Average time per task: {elapsed / max(self.tasks_completed, 1):.2f}s") + print("=" * 70 + "\n") + + +class EventLogSink(SinkFunction): + """ + SinkFunction that logs all device events. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.events: list[dict[str, Any]] = [] + + def execute(self, data: dict[str, Any]) -> None: + """Log device event.""" + self.events.append(data) + + def close(self) -> None: + """Display event log summary.""" + print("\n๐Ÿ“ Event Log Summary:") + print(f" Total events: {len(self.events)}") + + # Group by device + by_device: dict[str, int] = {} + for event in self.events: + device = event.get("device", "unknown") + by_device[device] = by_device.get(device, 0) + 1 + + print(" Events by device:") + for device, count in sorted(by_device.items()): + icon = { + "robot_001": "๐Ÿค–", + "washer_001": "๐Ÿงบ", + "dryer_001": "๐Ÿ’จ", + "humid_sensor_001": "๐Ÿ“Š", + }.get(device, "๐Ÿ“ฑ") + print(f" {icon} {device}: {count} events") diff --git a/apps/src/sage/apps/smart_home/pipeline.py b/apps/src/sage/apps/smart_home/pipeline.py new file mode 100644 index 0000000..b501aa9 --- /dev/null +++ b/apps/src/sage/apps/smart_home/pipeline.py @@ -0,0 +1,131 @@ +""" +Smart Home Pipeline + +Main pipeline implementation using SAGE operators for smart home automation. +""" + +from __future__ import annotations + +from sage.foundation import CustomLogger +from sage.runtime import LocalEnvironment + +from .operators import ( + DeviceExecutor, + EnvironmentMonitor, + EventLogSink, + LaundryWorkflowSource, + WorkflowProgressSink, +) + + +def run_smart_home_demo(num_cycles: int = 1, verbose: bool = False) -> None: + """ + Run the smart home laundry automation demonstration. + + Args: + num_cycles: Number of laundry cycles to run + verbose: Enable verbose logging + + Example: + >>> from sage.apps.smart_home import run_smart_home_demo + >>> run_smart_home_demo(num_cycles=1) + """ + print("\n" + "๐Ÿ " * 35) + print(" " * 15 + "SAGE Smart Home Automation Demo") + print("๐Ÿ " * 35) + print() + print("=" * 70) + print("๐Ÿ“‹ Configuration") + print("=" * 70) + print(f" Laundry cycles: {num_cycles}") + print(f" Verbose logging: {verbose}") + print("=" * 70) + print() + + print("=" * 70) + print("๐Ÿ”„ Automated Laundry Workflow") + print("=" * 70) + print(" Steps:") + print(" 1. Check environmental conditions (humidity)") + print(" 2. Robot collects laundry from basket") + print(" 3. Washer runs wash cycle") + print(" 4. Robot moves laundry to dryer") + print(" 5. Dryer runs dry cycle") + print(" 6. Robot moves laundry to drying rack") + print("=" * 70) + print() + + # Create SAGE environment + env = LocalEnvironment("smart_home") + + # Build pipeline: + # 1. LaundryWorkflowSource: Generate workflow tasks + # 2. DeviceExecutor: Execute tasks on simulated devices + # 3. EnvironmentMonitor: Monitor environmental conditions + # 4. WorkflowProgressSink: Track progress + # 5. EventLogSink: Log all events + pipeline = ( + env.from_batch( + LaundryWorkflowSource, + num_cycles=num_cycles, + ) + .map(DeviceExecutor) + .map(EnvironmentMonitor) + .sink(WorkflowProgressSink) + ) + + if verbose: + pipeline.sink(EventLogSink) + + # Execute pipeline + print("๐Ÿš€ Starting smart home automation...") + print() + env.submit(autostop=True) + + print("\n๐Ÿ " * 35) + print(" " * 20 + "Demo completed!") + print("๐Ÿ " * 35 + "\n") + + +def main(): + """Main entry point for command-line usage.""" + import argparse + + parser = argparse.ArgumentParser( + description="SAGE Smart Home System - IoT device automation demo", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run single laundry cycle + python -m sage.apps.smart_home.pipeline + + # Run multiple cycles + python -m sage.apps.smart_home.pipeline --cycles 3 + + # Verbose mode + python -m sage.apps.smart_home.pipeline --verbose + """, + ) + + parser.add_argument( + "--cycles", + "-c", + type=int, + default=1, + help="Number of laundry cycles to run (default: 1)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + # Run demo + run_smart_home_demo(num_cycles=args.cycles, verbose=args.verbose) + + +if __name__ == "__main__": + main() diff --git a/apps/src/sage/apps/video/__init__.py b/apps/src/sage/apps/video/__init__.py new file mode 100644 index 0000000..433bd6e --- /dev/null +++ b/apps/src/sage/apps/video/__init__.py @@ -0,0 +1,3 @@ +"""Video intelligence demos for the SAGE examples suite.""" + +__all__ = ["operators"] diff --git a/apps/src/sage/apps/video/config/default_config.yaml b/apps/src/sage/apps/video/config/default_config.yaml new file mode 100644 index 0000000..74e987b --- /dev/null +++ b/apps/src/sage/apps/video/config/default_config.yaml @@ -0,0 +1,63 @@ +# Default configuration for the SAGE video intelligence pipeline +# Copy this file and tweak the parameters for your own experiments. + +video_path: # Must be provided via --video argument or config override +# Process one frame every N frames. Increase to downsample for long videos. +sample_every_n_frames: 3 +# Uncomment to shorten runs for quick debugging +# max_frames: 180 +frame_resize: 336 + +analysis: + # CLIP templates that steer zero-shot scene understanding + clip_templates: + - Outdoor cycling activity + - Relaxing in a park + - Discussing in an office + - Working out in a gym + - Night-time city scene + - Casual indoor conversation + - Sports match in progress + - Crowded street + - Car driving on a road + - Animal present in frame + clip_top_k: 4 + classifier_top_k: 5 + min_event_confidence: 0.25 + brightness_delta_threshold: 35.0 + +window_summary: + window_seconds: 8.0 + stride_seconds: 4.0 + max_frames_per_window: 120 + summarizer_model: sshleifer/distilbart-cnn-12-6 + max_summary_tokens: 96 + +output: + timeline_path: artifacts/video_timeline.jsonl + summary_path: artifacts/video_summary.json + event_stats_path: artifacts/video_event_stats.json + preview_every: 12 + +integrations: + enable_sage_db: true + enable_sage_flow: true + enable_neuromem: true + + sage_db: + service_name: video_scene_db + dimension: 512 + index_type: AUTO + neighbor_k: 3 + + sage_flow: + service_name: video_vector_flow + dim: 512 + dtype: Float32 + auto_flush: 1 + + neuromem: + service_name: video_memory_service + collection_name: demo_collection + topk: 3 + with_metadata: true diff --git a/apps/src/sage/apps/video/operators/__init__.py b/apps/src/sage/apps/video/operators/__init__.py new file mode 100644 index 0000000..30c9a18 --- /dev/null +++ b/apps/src/sage/apps/video/operators/__init__.py @@ -0,0 +1,48 @@ +"""Operator collection for the video intelligence demo.""" + +from __future__ import annotations + +from importlib import import_module + +_SYMBOL_TO_MODULE = { + "VideoFrameSource": ".sources", + "FramePreprocessor": ".preprocessing", + "SceneConceptExtractor": ".perception", + "FrameObjectClassifier": ".perception", + "TemporalAnomalyDetector": ".analytics", + "FrameEventEmitter": ".analytics", + "SlidingWindowSummaryEmitter": ".analytics", + "FrameLightweightFormatter": ".formatters", + "SageMiddlewareIntegrator": ".integrations", + "SummaryMemoryAugmentor": ".integrations", + "TimelineSink": ".sinks", + "SummarySink": ".sinks", + "EventStatsSink": ".sinks", +} + + +def __getattr__(name: str): + module_name = _SYMBOL_TO_MODULE.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module = import_module(module_name, __name__) + value = getattr(module, name) + globals()[name] = value + return value + +__all__ = [ + "VideoFrameSource", + "FramePreprocessor", + "SceneConceptExtractor", + "FrameObjectClassifier", + "TemporalAnomalyDetector", + "FrameEventEmitter", + "SlidingWindowSummaryEmitter", + "FrameLightweightFormatter", + "SageMiddlewareIntegrator", + "SummaryMemoryAugmentor", + "TimelineSink", + "SummarySink", + "EventStatsSink", +] diff --git a/apps/src/sage/apps/video/operators/analytics.py b/apps/src/sage/apps/video/operators/analytics.py new file mode 100644 index 0000000..96f000e --- /dev/null +++ b/apps/src/sage/apps/video/operators/analytics.py @@ -0,0 +1,240 @@ +"""Analytics operators for the video intelligence demo.""" + +from __future__ import annotations + +from collections import Counter, deque +from collections.abc import Iterable +from typing import Any + +import torch + +from sage.foundation import CustomLogger, FlatMapFunction, MapFunction + +try: + from transformers import pipeline as hf_pipeline +except ImportError: # pragma: no cover - optional dependency + hf_pipeline = None + + +class TemporalAnomalyDetector(MapFunction): + """Detects lighting shifts and abrupt scene switches.""" + + def __init__(self, brightness_delta_threshold: float = 35.0) -> None: + super().__init__() + self.threshold = max(1.0, brightness_delta_threshold) + self.prev_brightness: float | None = None + self.prev_scene: str | None = None + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + brightness = data.get("brightness") + primary_scene = data.get("primary_scene") + + anomalies: list[dict[str, Any]] = [] + if brightness is not None and self.prev_brightness is not None: + delta = abs(brightness - self.prev_brightness) + if delta >= self.threshold: + anomalies.append( + { + "type": "lighting_shift", + "delta": float(delta), + } + ) + self.prev_brightness = brightness + + if primary_scene and self.prev_scene and primary_scene != self.prev_scene: + anomalies.append( + { + "type": "scene_transition", + "from": self.prev_scene, + "to": primary_scene, + } + ) + if primary_scene: + self.prev_scene = primary_scene + + data["anomalies"] = anomalies + return data + + +class FrameEventEmitter(FlatMapFunction): + """Emits structured events for downstream keyed aggregation.""" + + def __init__(self, min_confidence: float = 0.25) -> None: + super().__init__() + self.min_confidence = min_confidence + + def execute(self, data: dict[str, Any]) -> Iterable[dict[str, Any]]: + frame_id = data.get("frame_id") + timestamp = data.get("timestamp") + events: list[dict[str, Any]] = [] + + for concept in data.get("scene_concepts", []): + if concept["score"] < self.min_confidence: + continue + events.append( + { + "event_type": "scene_concept", + "label": concept["label"], + "score": concept["score"], + "frame_id": frame_id, + "timestamp": timestamp, + } + ) + + for prediction in data.get("object_predictions", []): + if prediction["score"] < self.min_confidence: + continue + events.append( + { + "event_type": "object", + "label": prediction["label"], + "score": prediction["score"], + "frame_id": frame_id, + "timestamp": timestamp, + } + ) + + for anomaly in data.get("anomalies", []): + events.append( + { + "event_type": anomaly.get("type", "anomaly"), + "frame_id": frame_id, + "timestamp": timestamp, + **{k: v for k, v in anomaly.items() if k != "type"}, + } + ) + + return events + + +class SlidingWindowSummaryEmitter(FlatMapFunction): + """Produces natural-language summaries for sliding windows of frames.""" + + def __init__( + self, + window_seconds: float, + stride_seconds: float, + max_frames: int, + summarizer_model: str | None, + max_summary_tokens: int, + ) -> None: + super().__init__() + self.window_seconds = max(1.0, float(window_seconds)) + self.stride_seconds = max(0.5, float(stride_seconds)) + self.max_frames = max(10, int(max_frames)) + self.max_summary_tokens = max(32, int(max_summary_tokens)) + self.window: deque[dict[str, Any]] = deque() + self.last_emit_timestamp: float | None = None + self.summary_index = 0 + + self.summarizer = None + if summarizer_model: + if hf_pipeline is None: + logger = CustomLogger( + outputs=[("console", "INFO")], name="SlidingWindowSummaryEmitter" + ) + logger.warning( + "transformers is not installed; summariser '%s' cannot be loaded. Falling back to template summaries.", + summarizer_model, + ) + else: + try: + device = 0 if torch.cuda.is_available() else -1 + self.summarizer = hf_pipeline( + "summarization", + model=summarizer_model, + device=device, + ) + except Exception as exc: # pragma: no cover - optional model + logger = CustomLogger( + outputs=[("console", "INFO")], + name="SlidingWindowSummaryEmitter", + ) + logger.warning( + "Failed to load summariser '%s': %s. Falling back to template summaries.", + summarizer_model, + exc, + ) + self.summarizer = None + + def execute(self, data: dict[str, Any]) -> Iterable[dict[str, Any]]: + record = { + "frame_id": data.get("frame_id"), + "timestamp": data.get("timestamp"), + "primary_scene": data.get("primary_scene"), + "scene_concepts": data.get("scene_concepts", []), + "object_predictions": data.get("object_predictions", []), + "anomalies": data.get("anomalies", []), + } + self.window.append(record) + + while self.window and ( + record["timestamp"] - self.window[0]["timestamp"] > self.window_seconds + or len(self.window) > self.max_frames + ): + self.window.popleft() + + should_emit = ( + self.last_emit_timestamp is None + or (record["timestamp"] - self.last_emit_timestamp) >= self.stride_seconds + ) + + if not should_emit or not self.window: + return [] + + self.last_emit_timestamp = record["timestamp"] + self.summary_index += 1 + return [self._build_summary()] + + def _build_summary(self) -> dict[str, Any]: + start_ts = self.window[0]["timestamp"] + end_ts = self.window[-1]["timestamp"] + + all_concepts = Counter() + all_objects = Counter() + anomalies = Counter() + captions: list[str] = [] + + for entry in self.window: + for concept in entry.get("scene_concepts", []): + all_concepts[concept["label"]] += concept["score"] + for obj in entry.get("object_predictions", []): + all_objects[obj["label"]] += obj["score"] + for anomaly in entry.get("anomalies", []): + anomalies[anomaly.get("type", "anomaly")] += 1 + if entry.get("primary_scene"): + captions.append(entry["primary_scene"]) + + def _top(counter: Counter, limit: int = 5) -> list[str]: + return [label for label, _ in counter.most_common(limit)] + + composed_text = " " + " ".join(captions) + if self.summarizer and composed_text.strip(): + try: + raw_summary = self.summarizer( + composed_text, + max_length=self.max_summary_tokens, + min_length=max(20, self.max_summary_tokens // 3), + do_sample=False, + )[0]["summary_text"].strip() + except Exception: # pragma: no cover - fallback path + raw_summary = self._fallback_summary(_top(all_concepts, 3), _top(all_objects, 5)) + else: + raw_summary = self._fallback_summary(_top(all_concepts, 3), _top(all_objects, 5)) + + return { + "summary_id": self.summary_index, + "start_timestamp": float(start_ts), + "end_timestamp": float(end_ts), + "duration_seconds": float(end_ts - start_ts), + "top_scene_concepts": _top(all_concepts, 5), + "top_objects": _top(all_objects, 5), + "anomaly_counts": dict(anomalies), + "generated_summary": raw_summary, + } + + @staticmethod + def _fallback_summary(concepts: list[str], objects: list[str]) -> str: + concept_text = ", ".join(concepts) if concepts else "diverse scenes" + object_text = ", ".join(objects) if objects else "generic objects" + return f"Window highlights {concept_text}. Frequent objects include {object_text}." diff --git a/apps/src/sage/apps/video/operators/formatters.py b/apps/src/sage/apps/video/operators/formatters.py new file mode 100644 index 0000000..372274f --- /dev/null +++ b/apps/src/sage/apps/video/operators/formatters.py @@ -0,0 +1,19 @@ +"""Formatting utilities for the video intelligence demo.""" + +from __future__ import annotations + +from typing import Any + +from sage.foundation import MapFunction + + +class FrameLightweightFormatter(MapFunction): + """Drops heavy artefacts and adds convenience fields.""" + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + data.pop("frame", None) + data.pop("pil_image", None) + data.pop("resized_image", None) + data["top_scene_labels"] = [entry["label"] for entry in data.get("scene_concepts", [])] + data["top_object_labels"] = [entry["label"] for entry in data.get("object_predictions", [])] + return data diff --git a/apps/src/sage/apps/video/operators/integrations.py b/apps/src/sage/apps/video/operators/integrations.py new file mode 100644 index 0000000..0724eac --- /dev/null +++ b/apps/src/sage/apps/video/operators/integrations.py @@ -0,0 +1,178 @@ +"""Service integration operators for the video intelligence demo.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from sage.foundation import MapFunction + + +class SageMiddlewareIntegrator(MapFunction): + """Publishes frame embeddings to SageDB and SageFlow services.""" + + def __init__( + self, + enable_db: bool = True, + db_service_name: str = "video_scene_db", + db_neighbor_k: int = 3, + enable_flow: bool = True, + flow_service_name: str = "video_vector_flow", + flow_auto_flush: int = 1, + ) -> None: + super().__init__() + self.enable_db = enable_db and bool(db_service_name) + self.db_service_name = db_service_name + self.db_neighbor_k = max(0, int(db_neighbor_k)) + self.enable_flow = enable_flow and bool(flow_service_name) + self.flow_service_name = flow_service_name + self.flow_auto_flush = max(1, int(flow_auto_flush)) + self._flow_since_flush = 0 + + def _format_neighbors( + self, neighbors: list[dict[str, Any]], entry_id: Any + ) -> list[dict[str, Any]]: + formatted: list[dict[str, Any]] = [] + for item in neighbors: + if item.get("id") == entry_id: + continue + meta = item.get("metadata", {}) or {} + formatted.append( + { + "score": round(float(item.get("score", 0.0)), 4), + "frame_id": meta.get("frame_id"), + "timestamp": meta.get("timestamp"), + "primary_scene": meta.get("primary_scene"), + } + ) + return formatted[: self.db_neighbor_k] + + def _flush_flow(self) -> None: + if not self.enable_flow or self._flow_since_flush <= 0: + return + try: + self.call_service(self.flow_service_name, method="run") + except Exception as exc: # pragma: no cover - runtime safety + self.logger.warning("SageFlow flush failed: %s", exc) + finally: + self._flow_since_flush = 0 + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + embedding = data.get("clip_image_embedding") + if embedding is None: + return data + + vector = np.asarray(embedding, dtype=np.float32) + vector_list = vector.tolist() + integrations = data.setdefault("integrations", {}) + + if self.enable_db: + metadata = { + "frame_id": str(data.get("frame_id")), + "timestamp": f"{float(data.get('timestamp', 0.0)):.3f}", + "primary_scene": data.get("primary_scene", ""), + } + try: + entry_id = self.call_service( + self.db_service_name, + vector_list, + metadata=metadata, + method="add", + ) + integrations["sage_db_entry_id"] = entry_id + + if self.db_neighbor_k > 0: + neighbors = self.call_service( + self.db_service_name, + vector_list, + method="search", + k=self.db_neighbor_k + 1, + include_metadata=True, + ) + integrations["sage_db_similar"] = self._format_neighbors( + neighbors or [], entry_id + ) + except Exception as exc: # pragma: no cover - service resilience + self.logger.warning("SageDB service call failed: %s", exc) + + if self.enable_flow: + try: + uid = int(data.get("frame_id", 0)) + self.call_service( + self.flow_service_name, + uid, + vector_list, + method="push", + ) + self._flow_since_flush += 1 + if self._flow_since_flush >= self.flow_auto_flush: + self._flush_flow() + except Exception as exc: # pragma: no cover - service resilience + self.logger.warning("SageFlow service call failed: %s", exc) + + data.pop("clip_image_embedding", None) + return data + + def __del__(self): # pragma: no cover - best-effort flush + try: + self._flush_flow() + except Exception: + pass + + +class SummaryMemoryAugmentor(MapFunction): + """Enriches summaries with NeuroMem memory retrieval results.""" + + def __init__( + self, + enable: bool = True, + service_name: str = "video_memory_service", + top_k: int = 3, + collection_name: str | None = None, + with_metadata: bool = True, + ) -> None: + super().__init__() + self.enable = enable and bool(service_name) + self.service_name = service_name + self.top_k = max(1, int(top_k)) + self.collection_name = collection_name + self.with_metadata = with_metadata + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + if not self.enable: + return data + + query = data.get("generated_summary") or " ".join(data.get("top_scene_concepts", [])) + if not query: + return data + + try: + results = self.call_service( + self.service_name, + query, + method="retrieve", + topk=self.top_k, + collection_name=self.collection_name, + with_metadata=self.with_metadata, + ) + except Exception as exc: # pragma: no cover - service resilience + self.logger.warning("NeuroMem retrieval failed: %s", exc) + return data + + if results: + formatted: list[dict[str, Any]] = [] + for item in results: + if isinstance(item, dict): + formatted.append( + { + "text": item.get("text") or item.get("history_query"), + "answer": item.get("answer") or item.get("metadata", {}).get("answer"), + "source": item.get("source_collection"), + } + ) + else: + formatted.append({"text": str(item), "source": None}) + data["memory_recall"] = formatted + + return data diff --git a/apps/src/sage/apps/video/operators/perception.py b/apps/src/sage/apps/video/operators/perception.py new file mode 100644 index 0000000..78936c2 --- /dev/null +++ b/apps/src/sage/apps/video/operators/perception.py @@ -0,0 +1,190 @@ +"""Perception operators for the video intelligence demo.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import numpy as np +import torch +import torch.nn.functional as func +from PIL import Image +from torchvision.models import MobileNet_V3_Large_Weights, mobilenet_v3_large + +from sage.foundation import MapFunction + +try: + from transformers import CLIPModel, CLIPProcessor +except ImportError as exc: # pragma: no cover - surface a friendly error + raise RuntimeError( + "transformers is required for SceneConceptExtractor. Install it via `pip install transformers`" + ) from exc + + +class SceneConceptExtractor(MapFunction): + """Zero-shot scene understanding using CLIP templates.""" + + def __init__( + self, + templates: Iterable[str], + top_k: int = 3, + device: str | None = None, + ) -> None: + super().__init__() + self.templates = list(templates) + if not self.templates: + raise ValueError("CLIP templates list cannot be empty") + + self.top_k = max(1, top_k) + self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") + + # Use smaller CLIP model to reduce memory usage + # Options ranked by size (smallest to largest): + # - openai/clip-vit-base-patch32: ~150MB, 86M params (BEST for memory) + # For even lower memory, we can use CPU and half precision + model_name = "openai/clip-vit-base-patch32" + + self.logger.info(f"Loading CLIP model: {model_name} on {self.device}") + + # Load with memory optimization + # Note: In test/CI environments without internet or with network restrictions, + # model loading may fail. We gracefully degrade to passthrough mode. + try: + # Use dtype instead of torch_dtype (torch_dtype is deprecated) + self.model = CLIPModel.from_pretrained( + model_name, + dtype=torch.float16 if self.device == "cuda" else torch.float32, + low_cpu_mem_usage=True, + ) + self.model = self.model.to(self.device) # type: ignore[assignment] + self.processor = CLIPProcessor.from_pretrained(model_name) + self.logger.info("CLIP model loaded successfully") + self.model_available = True + except Exception as e: + self.logger.warning( + f"Failed to load CLIP model (network/cache issue): {e}. " + "Operating in passthrough mode - scene concepts will not be extracted." + ) + self.model = None + self.processor = None + self.model_available = False + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + pil_image_raw = data.get("pil_image") + if pil_image_raw is None: + return data + if not isinstance(pil_image_raw, Image.Image): + return data + pil_image: Image.Image = pil_image_raw + + # If model failed to load, passthrough without scene concepts + if not self.model_available or not self.processor or not self.model: + data["scene_concepts"] = [] + data["scene_vector"] = None + return data + + inputs = self.processor( + text=self.templates, + images=pil_image, + return_tensors="pt", + padding=True, + ) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + + with torch.no_grad(): + outputs = self.model(**inputs) + scores = outputs.logits_per_image.softmax(dim=1)[0] + + if hasattr(outputs, "image_embeds") and outputs.image_embeds is not None: + image_features = outputs.image_embeds + else: + image_features = self.model.get_image_features( + pixel_values=inputs.get("pixel_values") + ) + + image_features = func.normalize(image_features, dim=-1) + data["clip_image_embedding"] = ( + image_features[0].detach().cpu().numpy().astype(np.float32) + ) + + top_k = min(self.top_k, scores.shape[-1]) + top_scores, top_indices = torch.topk(scores, top_k) + concepts: list[dict[str, Any]] = [] + for score, idx in zip(top_scores.tolist(), top_indices.tolist(), strict=True): + concepts.append( + { + "label": self.templates[idx], + "score": float(score), + } + ) + + data["scene_concepts"] = concepts + data["primary_scene"] = concepts[0]["label"] if concepts else "Unknown" + data["scene_confidence"] = float(concepts[0]["score"]) if concepts else 0.0 + return data + + +class FrameObjectClassifier(MapFunction): + """Image classification via MobileNetV3 over ImageNet classes.""" + + def __init__(self, top_k: int = 5, device: str | None = None) -> None: + super().__init__() + self.top_k = max(1, top_k) + self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") + + self.logger.info(f"Loading MobileNetV3 model on {self.device}") + + try: + weights = MobileNet_V3_Large_Weights.DEFAULT + self.model = mobilenet_v3_large(weights=weights).to(self.device) + self.model.eval() + + # Enable half precision for GPU to save memory + if self.device == "cuda": + self.model = self.model.half() + + self.preprocess = weights.transforms() + self.categories = weights.meta["categories"] + self.logger.info("MobileNetV3 model loaded successfully") + self.model_available = True + except Exception as e: + self.logger.warning( + f"Failed to load MobileNetV3 model: {e}. " + "Operating in passthrough mode - objects will not be detected." + ) + self.model = None + self.preprocess = None + self.categories = None + self.model_available = False + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + pil_image_raw = data.get("pil_image") + if pil_image_raw is None: + return data + if not isinstance(pil_image_raw, Image.Image): + return data + pil_image: Image.Image = pil_image_raw + + # If model failed to load, passthrough without object detection + if not self.model_available or not self.preprocess or not self.model or not self.categories: + data["detected_objects"] = [] + return data + + tensor = self.preprocess(pil_image).unsqueeze(0).to(self.device) + with torch.no_grad(): + logits = self.model(tensor) + probs = torch.nn.functional.softmax(logits, dim=1)[0] + + k = min(self.top_k, probs.shape[0]) + top_scores, top_indices = torch.topk(probs, k) + predictions: list[dict[str, Any]] = [] + for score, idx in zip(top_scores.tolist(), top_indices.tolist(), strict=True): + predictions.append( + { + "label": self.categories[idx], + "score": float(score), + } + ) + + data["object_predictions"] = predictions + return data diff --git a/apps/src/sage/apps/video/operators/preprocessing.py b/apps/src/sage/apps/video/operators/preprocessing.py new file mode 100644 index 0000000..8a4e991 --- /dev/null +++ b/apps/src/sage/apps/video/operators/preprocessing.py @@ -0,0 +1,34 @@ +"""Preprocessing operators for the video intelligence demo.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from PIL import Image + +from sage.foundation import MapFunction + + +class FramePreprocessor(MapFunction): + """Converts numpy frames to PIL, resizes, and extracts basic metrics.""" + + def __init__(self, target_size: int = 336) -> None: + super().__init__() + self.target_size = target_size + + def execute(self, data: dict[str, Any]) -> dict[str, Any]: + frame = data.get("frame") + if frame is None: + return data + + pil_image = Image.fromarray(frame) + data["pil_image"] = pil_image + data["brightness"] = float(np.mean(frame)) + + if self.target_size: + data["resized_image"] = pil_image.resize((self.target_size, self.target_size)) + else: + data["resized_image"] = pil_image + + return data diff --git a/apps/src/sage/apps/video/operators/sinks.py b/apps/src/sage/apps/video/operators/sinks.py new file mode 100644 index 0000000..66daf57 --- /dev/null +++ b/apps/src/sage/apps/video/operators/sinks.py @@ -0,0 +1,125 @@ +"""Sink operators for the video intelligence demo.""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Any + +from sage.foundation import SinkFunction + + +class TimelineSink(SinkFunction): + """Persists per-frame insights as JSONL.""" + + def __init__(self, output_path: str, preview_every: int = 10) -> None: + super().__init__() + self.output_path = Path(output_path) + self.output_path.parent.mkdir(parents=True, exist_ok=True) + self.preview_every = max(1, preview_every) + self.count = 0 + self.file = self.output_path.open("w", encoding="utf-8") + + def execute(self, data: dict[str, Any]) -> None: + safe_data = { + key: value + for key, value in data.items() + if key not in {"frame", "pil_image", "resized_image"} + } + self.file.write(json.dumps(safe_data, ensure_ascii=False) + "\n") + self.count += 1 + + # Enhanced console output for better visibility + if self.count % self.preview_every == 0: + frame_id = safe_data.get("frame_id", "?") + timestamp = safe_data.get("timestamp_seconds", 0) + scene = safe_data.get("primary_scene", "Unknown scene") + objects = ", ".join(safe_data.get("top_object_labels", [])[:3]) + brightness = safe_data.get("brightness", 0) + + print(f"\n{'=' * 70}") + print(f"๐Ÿ“น Frame {frame_id} @ {timestamp:.2f}s") + print(f" Scene: {scene}") + print(f" Objects: {objects or 'none detected'}") + print(f" Brightness: {brightness:.1f}") + + # Show scene scores if available + scene_scores = safe_data.get("scene_scores", {}) + if scene_scores: + top_scenes = sorted(scene_scores.items(), key=lambda x: x[1], reverse=True)[:3] + print(f" Top scenes: {', '.join(f'{s}({v:.2f})' for s, v in top_scenes)}") + + print(f"{'=' * 70}") + + def __del__(self) -> None: # pragma: no cover - defensive cleanup + if hasattr(self, "file") and self.file and not self.file.closed: + self.file.close() + + +class SummarySink(SinkFunction): + """Collects sliding-window summaries and writes a compact JSON report.""" + + def __init__(self, summary_path: str) -> None: + super().__init__() + self.summary_path = Path(summary_path) + self.summary_path.parent.mkdir(parents=True, exist_ok=True) + self.records: list[dict[str, Any]] = [] + + def execute(self, data: dict[str, Any]) -> None: + self.records.append(data) + + # Print summary to console + window_id = len(self.records) + summary_text = data.get("summary", "") + start_time = data.get("window_start_seconds", 0) + end_time = data.get("window_end_seconds", 0) + + print(f"\n๐ŸŽฌ Summary Window #{window_id} ({start_time:.1f}s - {end_time:.1f}s)") + print(f" {summary_text[:200]}{'...' if len(summary_text) > 200 else ''}") + + # Show memory recall if available + memory_recall = data.get("memory_recall", []) + if memory_recall: + print(f" ๐Ÿ’ญ Memory recalls: {len(memory_recall)} similar moments found") + + def __del__(self) -> None: # pragma: no cover - defensive cleanup + if self.records: + payload = { + "window_count": len(self.records), + "summaries": self.records, + } + with self.summary_path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + + +class EventStatsSink(SinkFunction): + """Aggregates event statistics and persists them on teardown.""" + + def __init__(self, stats_path: str, log_every: int = 25) -> None: + super().__init__() + self.stats_path = Path(stats_path) + self.stats_path.parent.mkdir(parents=True, exist_ok=True) + self.counter: Counter[str] = Counter() + self.total_events = 0 + self.log_every = max(1, log_every) + + def execute(self, event: dict[str, Any]) -> None: + event_type = event.get("event_type", "unknown") + self.counter[event_type] += 1 + self.total_events += 1 + + if self.total_events % self.log_every == 0: + top_events = self.counter.most_common(3) + top_str = ", ".join(f"{t}({c})" for t, c in top_events) + print(f"\n๐Ÿ“Š Events processed: {self.total_events}") + print(f" Top events: {top_str}") + + def __del__(self) -> None: # pragma: no cover - defensive cleanup + if self.total_events: + payload = { + "total_events": self.total_events, + "event_counts": dict(self.counter), + } + with self.stats_path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) diff --git a/apps/src/sage/apps/video/operators/sources.py b/apps/src/sage/apps/video/operators/sources.py new file mode 100644 index 0000000..6642fb4 --- /dev/null +++ b/apps/src/sage/apps/video/operators/sources.py @@ -0,0 +1,73 @@ +"""Source operators for the video intelligence demo.""" + +from __future__ import annotations + +import os +from typing import Any + +import cv2 + +from sage.foundation import BatchFunction + + +class VideoFrameSource(BatchFunction): + """Reads frames from a video file as a BatchFunction source.""" + + def __init__( + self, + video_path: str, + sample_every_n_frames: int = 3, + max_frames: int | None = None, + ) -> None: + super().__init__() + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + self.video_path = video_path + self.sample_every = max(1, int(sample_every_n_frames)) + self.max_frames = max_frames if max_frames and max_frames > 0 else None + self.cap = cv2.VideoCapture(video_path) + if not self.cap.isOpened(): + raise RuntimeError(f"Failed to open video: {video_path}") + + fps = self.cap.get(cv2.CAP_PROP_FPS) or 0.0 + self.fps = float(fps if fps > 0 else 30.0) + self.frame_cursor = 0 + self.frames_emitted = 0 + # logger is provided by BaseFunction via ctx + + def execute(self) -> dict[str, Any] | None: + if not self.cap or not self.cap.isOpened(): + return None + + while True: + ret, frame = self.cap.read() + if not ret: + self.cap.release() + return None + + current_index = self.frame_cursor + self.frame_cursor += 1 + + if (current_index % self.sample_every) != 0: + continue # skip until we hit the sampling cadence + + if self.max_frames and self.frames_emitted >= self.max_frames: + self.cap.release() + return None + + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + timestamp = current_index / self.fps + self.frames_emitted += 1 + + return { + "frame_id": int(current_index), + "timestamp": float(timestamp), + "fps": self.fps, + "frame": frame_rgb, + "video_path": self.video_path, + } + + def __del__(self) -> None: # pragma: no cover - defensive cleanup + if hasattr(self, "cap") and self.cap and self.cap.isOpened(): + self.cap.release() diff --git a/apps/src/sage/apps/video/video_intelligence_pipeline.py b/apps/src/sage/apps/video/video_intelligence_pipeline.py new file mode 100644 index 0000000..be682aa --- /dev/null +++ b/apps/src/sage/apps/video/video_intelligence_pipeline.py @@ -0,0 +1,614 @@ +"""Advanced video intelligence demo built on SAGE. + +This example upgrades the original notebook-only demo with a fully scripted +pipeline that showcases SAGE's declarative operators (Batch, Map, FlatMap, +KeyBy, Sink) and combines multiple AI capabilities: + +* Frame sampling and preprocessing +* Zero-shot scene understanding with CLIP +* Image classification with MobileNetV3 +* Lightweight temporal anomaly detection +* Sliding-window summarisation backed by an optional HuggingFace summariser +* Structured event stream with keyed aggregation for observability + +@test:skip - Requires HuggingFace model downloads (CLIP, MobileNetV3). +CI environments may have network restrictions preventing model downloads. +Run with: python -m sage.apps.video.video_intelligence_pipeline +Or use: python examples/apps/run_video_intelligence.py +""" + +from __future__ import annotations + +import argparse +import os +import urllib.request +from pathlib import Path +from typing import Any + +import yaml + +from sage.foundation import CustomLogger +from sage.runtime import LocalEnvironment + + +# Helper function to get logger +def get_logger(name: str) -> CustomLogger: + """Get a CustomLogger instance with the given name.""" + return CustomLogger(outputs=[("console", "INFO")], name=name) + + +# Optional service integrations were previously provided by retired auxiliary +# repositories. The consolidated example now keeps the hooks but disables them +# unless equivalent in-tree services are added back explicitly. +MemoryServiceRegistry = None # type: ignore[assignment] +NeuromemServiceFactory = None # type: ignore[assignment] +NeuroMemVDBService = None # type: ignore[assignment] +SageDBService = None # type: ignore[assignment] +SageFlowService = None # type: ignore[assignment] + +# Default config path +DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config" / "default_config.yaml" + + +def _load_video_operator_symbols() -> dict[str, Any]: + """Lazily load heavy video operators. + + This keeps lightweight actions such as ``--help`` usable even when optional + vision dependencies like ``torchvision`` are not installed. + """ + + from sage.apps.video import operators as video_operators + + required_symbols = [ + "EventStatsSink", + "FrameEventEmitter", + "FrameLightweightFormatter", + "FrameObjectClassifier", + "FramePreprocessor", + "SageMiddlewareIntegrator", + "SceneConceptExtractor", + "SlidingWindowSummaryEmitter", + "SummaryMemoryAugmentor", + "SummarySink", + "TemporalAnomalyDetector", + "TimelineSink", + "VideoFrameSource", + ] + + try: + return {name: getattr(video_operators, name) for name in required_symbols} + except Exception as exc: # pragma: no cover - optional dependency surface + raise RuntimeError( + "Video intelligence demo requires optional vision dependencies. " + "Install packages such as torch, torchvision, pillow and transformers " + "before running the full pipeline." + ) from exc + + +def load_config(config_path: str | None) -> dict[str, Any]: + """Load YAML configuration for the demo.""" + + # If user provided a config path, use it + if config_path: + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError(f"Config file {path} must define a mapping at the top level") + print(f"[INFO] Loaded config from: {path}") + return data + + # Use default config + if DEFAULT_CONFIG_PATH.exists(): + with DEFAULT_CONFIG_PATH.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError( + f"Config file {DEFAULT_CONFIG_PATH} must define a mapping at the top level" + ) + print(f"[INFO] Loaded config from: {DEFAULT_CONFIG_PATH}") + return data + + # If no config file found, return default configuration + print("[WARNING] No config file found") + print("[INFO] Using built-in default configuration") + return { + "video_path": None, + "output_dir": "output/video_intelligence", + "max_frames": None, + "sample_every_n_frames": 3, + "frame_resize": 336, + "analysis": { + "clip_top_k": 4, + "classifier_top_k": 5, + "min_event_confidence": 0.25, + }, + "models": { + "clip": "openai/clip-vit-base-patch32", + "mobilenet": "google/mobilenet_v3_small_100_224", + }, + } + + +def download_test_video(output_path: str) -> bool: + """Download a small test video for automated testing. + + Args: + output_path: Path where the video should be saved + + Returns: + True if download succeeded, False otherwise + """ + # Use a small sample video from a public source + # This is a ~1MB sample video suitable for testing + test_video_url = "https://sample-videos.com/video321/mp4/240/big_buck_bunny_240p_1mb.mp4" + + logger = CustomLogger("video_downloader") + + try: + logger.info(f"Downloading test video from {test_video_url}") + logger.info(f"Saving to {output_path}") + + # Create parent directory if it doesn't exist + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + # Download the file + urllib.request.urlretrieve(test_video_url, output_path) + + # Verify the file exists and has content + if os.path.exists(output_path) and os.path.getsize(output_path) > 0: + logger.info( + f"Successfully downloaded test video ({os.path.getsize(output_path)} bytes)" + ) + return True + else: + logger.error("Downloaded file is empty or doesn't exist") + return False + + except Exception as e: + logger.error(f"Failed to download test video: {e}") + return False + + +def ensure_video_exists(video_path: str, auto_download: bool = True) -> str: + """Ensure a video file exists, downloading a test video if needed. + + Args: + video_path: Configured video path + auto_download: Whether to automatically download a test video if file doesn't exist + + Returns: + Path to the video file + + Raises: + FileNotFoundError: If video doesn't exist and auto_download is False or download failed + """ + logger = CustomLogger("video_validator") + + # If the configured path exists, use it + if os.path.exists(video_path): + logger.info(f"Using existing video file: {video_path}") + return video_path + + # If auto-download is disabled, raise an error + if not auto_download: + raise FileNotFoundError( + f"Video file '{video_path}' does not exist. Provide --video or update the config." + ) + + # Try to download a test video + logger.warning(f"Video file '{video_path}' not found. Attempting to download test video...") + + # Determine download path + if video_path == "./video_demo.mp4": + # Default case: download to current directory or sage.apps.video location + download_path = str(Path(__file__).parent / "video_demo.mp4") + else: + # Use the configured path + download_path = video_path + + if download_test_video(download_path): + logger.info(f"Test video ready at: {download_path}") + return download_path + else: + raise FileNotFoundError( + f"Video file '{video_path}' does not exist and automatic download failed. " + f"Please provide a video file using --video or update the config." + ) + + +# --------------------------------------------------------------------------- +# Pipeline assembly +# --------------------------------------------------------------------------- + + +def build_pipeline(env: LocalEnvironment, config: dict[str, Any]) -> None: + operator_symbols = _load_video_operator_symbols() + VideoFrameSource = operator_symbols["VideoFrameSource"] + FramePreprocessor = operator_symbols["FramePreprocessor"] + SceneConceptExtractor = operator_symbols["SceneConceptExtractor"] + FrameObjectClassifier = operator_symbols["FrameObjectClassifier"] + TemporalAnomalyDetector = operator_symbols["TemporalAnomalyDetector"] + SageMiddlewareIntegrator = operator_symbols["SageMiddlewareIntegrator"] + FrameLightweightFormatter = operator_symbols["FrameLightweightFormatter"] + TimelineSink = operator_symbols["TimelineSink"] + FrameEventEmitter = operator_symbols["FrameEventEmitter"] + EventStatsSink = operator_symbols["EventStatsSink"] + SlidingWindowSummaryEmitter = operator_symbols["SlidingWindowSummaryEmitter"] + SummaryMemoryAugmentor = operator_symbols["SummaryMemoryAugmentor"] + SummarySink = operator_symbols["SummarySink"] + + video_path = config["video_path"] + sample_every = config["sample_every_n_frames"] + max_frames = config.get("max_frames") + frame_resize = config.get("frame_resize") + + analysis_cfg = config.get("analysis", {}) + window_cfg = config.get("window_summary", {}) + output_cfg = config.get("output", {}) + integrations_cfg = config.get("integrations", {}) + + logger = get_logger("video_intelligence_pipeline") + + if any( + bool(integrations_cfg.get(flag, False)) + for flag in ("enable_sage_db", "enable_sage_flow", "enable_neuromem") + ): + logger.warning( + "Optional external middleware integrations are retired from the consolidated demo. " + "Only in-tree SAGE operators and services are supported." + ) + + # ------------------------------------------------------------------ + # Service registrations (SageDB, SageFlow, NeuroMem) + # ------------------------------------------------------------------ + db_cfg = integrations_cfg.get("sage_db", {}) + db_service_name = db_cfg.get("service_name", "video_scene_db") + db_dimension = int(db_cfg.get("dimension", 512)) + enable_db = bool(integrations_cfg.get("enable_sage_db", False)) + if enable_db and SageDBService is None: + logger.warning( + "SageDBService is unavailable (module missing). Disabling SageDB integration." + ) + enable_db = False + + flow_cfg = integrations_cfg.get("sage_flow", {}) + flow_service_name = flow_cfg.get("service_name", "video_vector_flow") + flow_dim = int(flow_cfg.get("dim", 512)) + flow_dtype = flow_cfg.get("dtype", "Float32") + enable_flow = bool(integrations_cfg.get("enable_sage_flow", False)) + if enable_flow and SageFlowService is None: + logger.warning( + "SageFlowService is unavailable (module missing). Disabling SageFlow integration." + ) + enable_flow = False + + memory_cfg = integrations_cfg.get("neuromem", {}) + memory_service_name = memory_cfg.get("service_name", "video_memory_service") + memory_collection = memory_cfg.get("collection_name", "demo_collection") + enable_neuromem = bool(integrations_cfg.get("enable_neuromem", False)) + + # Disable NeuroMem in test mode (requires pre-created collection) + if os.environ.get("SAGE_EXAMPLES_MODE") == "test": + if enable_neuromem: + logger.info("Test mode: Disabling NeuroMem (requires collection setup)") + enable_neuromem = False + + if enable_neuromem and NeuroMemVDBService is None: + logger.warning( + "NeuroMemVDBService is unavailable (module missing). Disabling NeuroMem integration." + ) + enable_neuromem = False + + if enable_db: + if not SageDBService: + raise RuntimeError("SageDBService not available") + try: + env.register_service( + db_service_name, + SageDBService, + dimension=db_dimension, + index_type=db_cfg.get("index_type", "AUTO"), + ) + logger.info( + "Registered SageDB service '%s' (dim=%d)", + db_service_name, + db_dimension, + ) + except Exception as exc: # pragma: no cover - runtime resilience + logger.error("Failed to register SageDB service: %s", exc) + enable_db = False + + if enable_flow: + if not SageFlowService: + raise RuntimeError("SageFlowService not available") + try: + env.register_service( + flow_service_name, + SageFlowService, + dim=flow_dim, + dtype=flow_dtype, + ) + logger.info( + "Registered SageFlow service '%s' (dim=%d, dtype=%s)", + flow_service_name, + flow_dim, + flow_dtype, + ) + except Exception as exc: # pragma: no cover - runtime resilience + logger.error("Failed to register SageFlow service: %s", exc) + enable_flow = False + + if enable_neuromem: + if not NeuroMemVDBService: + raise RuntimeError("NeuroMemVDBService not available") + try: + env.register_service( + memory_service_name, + NeuroMemVDBService, + collection_name=memory_collection, + ) + logger.info( + "Registered NeuroMem service '%s' (collection=%s)", + memory_service_name, + memory_collection, + ) + except Exception as exc: # pragma: no cover - runtime resilience + logger.error("Failed to register NeuroMem service: %s", exc) + enable_neuromem = False + + source_stream = env.from_source( + VideoFrameSource, + video_path=video_path, + sample_every_n_frames=sample_every, + max_frames=max_frames, + ) + + annotated_stream = ( + source_stream.map(FramePreprocessor, target_size=frame_resize) + .map( + SceneConceptExtractor, + templates=analysis_cfg.get("clip_templates", []), + top_k=analysis_cfg.get("clip_top_k", 4), + ) + .map( + FrameObjectClassifier, + top_k=analysis_cfg.get("classifier_top_k", 5), + ) + .map( + TemporalAnomalyDetector, + brightness_delta_threshold=analysis_cfg.get("brightness_delta_threshold", 35.0), + ) + .map( + SageMiddlewareIntegrator, + enable_db=enable_db, + db_service_name=db_service_name, + db_neighbor_k=int(db_cfg.get("neighbor_k", 3)), + enable_flow=enable_flow, + flow_service_name=flow_service_name, + flow_auto_flush=int(flow_cfg.get("auto_flush", 1)), + ) + .map(FrameLightweightFormatter) + ) + + annotated_stream.sink( + TimelineSink, + output_path=output_cfg.get("timeline_path"), + preview_every=output_cfg.get("preview_every", 12), + ) + + events_stream = annotated_stream.flatmap( + FrameEventEmitter, + min_confidence=analysis_cfg.get("min_event_confidence", 0.22), + ) + + events_stream.sink( + EventStatsSink, + stats_path=output_cfg.get("event_stats_path"), + log_every=30, + ) + + summary_stream = annotated_stream.flatmap( + SlidingWindowSummaryEmitter, + window_seconds=window_cfg.get("window_seconds", 8.0), + stride_seconds=window_cfg.get("stride_seconds", 4.0), + max_frames=window_cfg.get("max_frames_per_window", 120), + summarizer_model=window_cfg.get("summarizer_model"), + max_summary_tokens=window_cfg.get("max_summary_tokens", 90), + ) + + summary_stream = summary_stream.map( + SummaryMemoryAugmentor, + enable=enable_neuromem, + service_name=memory_service_name, + top_k=int(memory_cfg.get("topk", 3)), + collection_name=memory_collection, + with_metadata=bool(memory_cfg.get("with_metadata", True)), + ) + + summary_stream.sink( + SummarySink, + summary_path=output_cfg.get("summary_path"), + ) + + +# --------------------------------------------------------------------------- +# CLI wiring +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the advanced SAGE video intelligence pipeline" + ) + parser.add_argument( + "--video", + dest="video_path", + type=str, + help="Path to a local video file (overrides config)", + ) + parser.add_argument( + "--config", + dest="config_path", + type=str, + help="Optional YAML config overriding defaults", + ) + parser.add_argument( + "--output-dir", + dest="output_dir", + type=str, + help="Optional base directory for generated artefacts", + ) + parser.add_argument( + "--max-frames", + dest="max_frames", + type=int, + help="Limit the number of frames processed (for quick demos)", + ) + return parser.parse_args() + + +def apply_runtime_overrides(config: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: + if args.video_path: + config["video_path"] = args.video_path + if args.max_frames is not None: + config["max_frames"] = args.max_frames + + output_dir = args.output_dir + if output_dir: + out_cfg = config.setdefault("output", {}) + base = Path(output_dir) + out_cfg["timeline_path"] = str(base / "timeline.jsonl") + out_cfg["summary_path"] = str(base / "summary.json") + out_cfg["event_stats_path"] = str(base / "event_stats.json") + + return config + + +def download_test_video_to_temp() -> str | None: + """Download a small test video for CI/testing purposes. + + Uses a public domain short video clip suitable for testing. + Returns the path to the downloaded video file, or None if download fails. + """ + import urllib.request + from pathlib import Path + + # Use a small public domain video (approx 1-2MB) + # Sample videos from Pexels (free to use) + test_video_url = ( + "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4" + ) + + # Download to a temp location + video_dir = Path("/tmp/sage_test_videos") + video_dir.mkdir(exist_ok=True) + video_path = video_dir / "test_video.mp4" + + if video_path.exists(): + print(f"[INFO] Test video already exists at {video_path}") + return str(video_path) + + try: + print(f"[INFO] Downloading test video from {test_video_url}") + urllib.request.urlretrieve(test_video_url, str(video_path)) + print(f"[INFO] Test video downloaded to {video_path}") + return str(video_path) + except Exception as e: + print(f"[WARNING] Failed to download test video: {e}") + return None + + +def main() -> None: + args = parse_args() + config = load_config(args.config_path) + config = apply_runtime_overrides(config, args) + + video_path = config.get("video_path") + + # If no video file provided, try to download a test video + if not video_path or not os.path.exists(video_path): + print("\n" + "=" * 80) + print("๐Ÿ“ฅ No video file provided - attempting to download test video...") + print("=" * 80) + test_video = download_test_video_to_temp() + if test_video: + video_path = test_video + config["video_path"] = video_path + print(f"โœ… Test video downloaded: {test_video}") + + # In test mode, limit frames for faster testing + if os.environ.get("SAGE_EXAMPLES_MODE") == "test" and "max_frames" not in config: + config["max_frames"] = 30 + print("[INFO] Test mode: Limiting to 30 frames for faster testing") + else: + print("\n" + "=" * 80) + print("โŒ Error: Could not download test video") + print("=" * 80) + print("\nPlease provide a video file using one of these methods:") + print(" 1. Command line: --video path/to/video.mp4") + print(" 2. Config file: Set 'video_path' in the config YAML") + print("\nExample:") + print(" python examples/apps/run_video_intelligence.py --video my_video.mp4") + print("=" * 80 + "\n") + raise FileNotFoundError( + "Could not download test video and no video file provided. " + "Please provide --video or update the config." + ) + print("=" * 80 + "\n") + + # Double check video file exists + if not video_path or not os.path.exists(video_path): + raise FileNotFoundError(f"Video file '{video_path}' does not exist.") + + env = LocalEnvironment("video_intelligence_demo") + build_pipeline(env, config) + + logger = get_logger("video_intelligence_demo") + logger.info("Starting pipeline on video '%s'", video_path) + + print("\n" + "=" * 80) + print("๐ŸŽฅ SAGE Video Intelligence Pipeline") + print("=" * 80) + print(f"๐Ÿ“ Video: {video_path}") + print(f"๐Ÿ“Š Config: {config.get('video_path', 'default')}") + print("โš™๏ธ Services: ", end="") + services = [] + if config.get("integrations", {}).get("enable_sage_db"): + services.append("SageDB") + if config.get("integrations", {}).get("enable_sage_flow"): + services.append("SageFlow") + if ( + config.get("integrations", {}).get("enable_neuromem") + and os.environ.get("SAGE_EXAMPLES_MODE") != "test" + ): + services.append("NeuroMem") + print(", ".join(services) if services else "None") + print("=" * 80 + "\n") + + # Submit and wait for pipeline to complete + env.submit(autostop=True) + + print("\n" + "=" * 80) + print("โœ… Pipeline execution completed!") + + # Show output file locations + output_cfg = config.get("output", {}) + if output_cfg: + print("\n๐Ÿ“‚ Output files generated:") + if output_cfg.get("timeline_path"): + print(f" โ€ข Timeline: {output_cfg['timeline_path']}") + if output_cfg.get("summary_path"): + print(f" โ€ข Summary: {output_cfg['summary_path']}") + if output_cfg.get("event_stats_path"): + print(f" โ€ข Events: {output_cfg['event_stats_path']}") + print("=" * 80 + "\n") + + logger.info("Pipeline execution completed") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/apps/src/sage/apps/work_report_generator/README.md b/apps/src/sage/apps/work_report_generator/README.md new file mode 100644 index 0000000..dc3f6a6 --- /dev/null +++ b/apps/src/sage/apps/work_report_generator/README.md @@ -0,0 +1,286 @@ +# Work Report Generator + +ๅŸบไบŽ SAGE ๆก†ๆžถ็š„ๅ‘จๆŠฅ/ๆ—ฅๆŠฅ่‡ชๅŠจ็”Ÿๆˆๅ™จใ€‚ + +## ๅŠŸ่ƒฝ็‰นๆ€ง + +- **GitHub ๆ•ฐๆฎ่Žทๅ–**: ่‡ชๅŠจ่Žทๅ–ๆŒ‡ๅฎšไป“ๅบ“ๅ’Œๅˆ†ๆ”ฏ็š„ commits ๅ’Œ Pull Requests +- **ๅญๆจกๅ—ๆ”ฏๆŒ**: ้ป˜่ฎคๆ”ถ้›† SAGE ไธปไป“ๅบ“ๅŠๆ‰€ๆœ‰ๅญๆจกๅ—็š„่ดก็Œฎ +- **่ดก็Œฎ่€…็ปŸ่ฎก**: ๆŒ‰่ดก็Œฎ่€…่šๅˆ็ปŸ่ฎกๆ•ฐๆฎ +- **AI ๆ‘˜่ฆ็”Ÿๆˆ**: ไฝฟ็”จ LLM ็”Ÿๆˆๆ™บ่ƒฝๅทฅไฝœๆ€ป็ป“ +- **ๅคš็ง่พ“ๅ‡บๆ ผๅผ**: ๆ”ฏๆŒ Markdownใ€JSONใ€Console ่พ“ๅ‡บ +- **ๆ—ฅ่ฎฐ้›†ๆˆ**: ๅฏ้€‰้›†ๆˆไธชไบบๅทฅไฝœๆ—ฅๅฟ— +- **CI/CD ้›†ๆˆ**: ๆ”ฏๆŒ่‡ชๅŠจๅŒ–ๅฎšๆ—ถ็”Ÿๆˆ๏ผˆๆฏๅ‘จไธ€ 17:00 ๅŒ—ไบฌๆ—ถ้—ด๏ผ‰ + +## ๅฟซ้€Ÿๅผ€ๅง‹ + +### ๅฎ‰่ฃ… + +```bash +# ๅฎ‰่ฃ… sage-apps +pip install -e packages/sage-apps +``` + +### ๅŸบๆœฌไฝฟ็”จ + +```python +from sage.apps.work_report_generator import run_work_report_pipeline + +# ็”Ÿๆˆ้ป˜่ฎคๆŠฅๅ‘Š๏ผˆๆ‰€ๆœ‰ SAGE ไป“ๅบ“๏ผŒmain-dev ๅˆ†ๆ”ฏ๏ผŒ่ฟ‡ๅŽป7ๅคฉ๏ผ‰ +run_work_report_pipeline() + +# ่‡ชๅฎšไน‰้…็ฝฎ +run_work_report_pipeline( + repos=["intellistream/SAGE", "intellistream/sageLLM"], + branch="main-dev", # ๆŒ‡ๅฎšๅˆ†ๆ”ฏ + days=14, + output_format="markdown", + output_path="reports/biweekly_report.md", + language="zh", # ไธญๆ–‡ๆŠฅๅ‘Š + include_submodules=False, # ไป…ๆŒ‡ๅฎšไป“ๅบ“๏ผŒไธๅŒ…ๅซๅญๆจกๅ— +) +``` + +### ๅ‘ฝไปค่กŒไฝฟ็”จ + +```bash +# ๅŸบๆœฌไฝฟ็”จ๏ผˆๆ”ถ้›†ๆ‰€ๆœ‰ SAGE ไป“ๅบ“ main-dev ๅˆ†ๆ”ฏ็š„่ดก็Œฎ๏ผ‰ +python -m sage.apps.work_report_generator.pipeline + +# ไป…ไธปไป“ๅบ“๏ผŒไธๅŒ…ๅซๅญๆจกๅ— +python -m sage.apps.work_report_generator.pipeline --no-submodules + +# ๆŒ‡ๅฎšๅˆ†ๆ”ฏ๏ผˆๅฆ‚ main ่€Œ้ž main-dev๏ผ‰ +python -m sage.apps.work_report_generator.pipeline --branch main + +# ๆŒ‡ๅฎšไป“ๅบ“ๅ’Œๆ—ถ้—ด่Œƒๅ›ด +python -m sage.apps.work_report_generator.pipeline \ + --repos intellistream/SAGE,intellistream/sageLLM \ + --days 14 + +# JSON ๆ ผๅผ่พ“ๅ‡บ +python -m sage.apps.work_report_generator.pipeline \ + --format json \ + --output reports/weekly.json + +# ่‹ฑๆ–‡ๆŠฅๅ‘Š๏ผŒไธไฝฟ็”จ LLM +python -m sage.apps.work_report_generator.pipeline \ + --language en \ + --no-llm +``` + +### ็คบไพ‹่„šๆœฌ + +```bash +python examples/apps/run_work_report.py --help +python examples/apps/run_work_report.py --days 7 +``` + +## ้ป˜่ฎคไป“ๅบ“ๅˆ—่กจ + +ๅฝ“ไธๆŒ‡ๅฎš `repos` ๅ‚ๆ•ฐๆ—ถ๏ผŒpipeline ้ป˜่ฎคๆ”ถ้›†ไปฅไธ‹ๆ‰€ๆœ‰ไป“ๅบ“็š„่ดก็Œฎ๏ผš + +| ไป“ๅบ“ | ่ฏดๆ˜Ž | +| ------------------------ | ------------------ | +| `intellistream/SAGE` | ไธปไป“ๅบ“ | +| `intellistream/SAGE-Pub` | ๆ–‡ๆกฃ (docs-public) | +| `intellistream/sageData` | Benchmark ๆ•ฐๆฎ | +| `intellistream/sageLLM` | LLM ่ฐƒๅบฆๆจกๅ— | +| `intellistream/LibAMM` | ่ฟ‘ไผผ็Ÿฉ้˜ตไน˜ๆณ•ๅบ“ | +| `intellistream/sageVDB` | ๆ•ฐๆฎๅบ“ไธญ้—ดไปถ | +| `intellistream/sageFlow` | ๆตๅค„็†ๅผ•ๆ“Ž | +| `intellistream/neuromem` | ๅ†…ๅญ˜็ฎก็†ๆจกๅ— | +| `intellistream/sageTSDB` | ๆ—ถๅบๆ•ฐๆฎๅบ“ | + +## ้…็ฝฎ้€‰้กน + +| ๅ‚ๆ•ฐ | ็ฑปๅž‹ | ้ป˜่ฎคๅ€ผ | ่ฏดๆ˜Ž | +| -------------------- | --------- | ---------- | ---------------------------------------- | +| `repos` | list[str] | None | GitHub ไป“ๅบ“ๅˆ—่กจ๏ผŒNone ่กจ็คบๆ‰€ๆœ‰ SAGE ไป“ๅบ“ | +| `branch` | str | "main-dev" | ็›ฎๆ ‡ๅˆ†ๆ”ฏๅ็งฐ | +| `days` | int | 7 | ็ปŸ่ฎกๅคฉๆ•ฐ | +| `output_format` | str | "markdown" | ่พ“ๅ‡บๆ ผๅผ (console/markdown/json) | +| `output_path` | str | None | ่พ“ๅ‡บๆ–‡ไปถ่ทฏๅพ„ | +| `diary_path` | str | None | ๆ—ฅ่ฎฐๆ–‡ไปถ/็›ฎๅฝ•่ทฏๅพ„ | +| `language` | str | "zh" | ๆŠฅๅ‘Š่ฏญ่จ€ (zh/en) | +| `github_token` | str | None | GitHub Token (ๅฏ็”จ็Žฏๅขƒๅ˜้‡) | +| `use_llm` | bool | True | ๆ˜ฏๅฆไฝฟ็”จ LLM ็”Ÿๆˆๆ‘˜่ฆ | +| `include_submodules` | bool | True | ๆ˜ฏๅฆๅŒ…ๅซๅญๆจกๅ—ไป“ๅบ“ | + +## ็Žฏๅขƒๅ˜้‡ + +```bash +# GitHub Token (ๅฟ…้œ€๏ผŒ็”จไบŽ API ่ฎฟ้—ฎ) +export GITHUB_TOKEN=ghp_xxxxxxxxxxxx + +# ๆˆ–ไฝฟ็”จ GIT_TOKEN +export GIT_TOKEN=ghp_xxxxxxxxxxxx +``` + +## ๆžถๆž„่ฎพ่ฎก + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Work Report Pipeline โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ GitHubDataSource โ”€โ”€โ”ฌโ”€โ”€โ–บ ContributorAggregator โ”‚ +โ”‚ (main-dev branch) โ”‚ โ”‚ โ”‚ +โ”‚ - SAGE โ”‚ โ–ผ โ”‚ +โ”‚ - sageLLM โ”‚ LLMReportGenerator โ”‚ +โ”‚ - sageVDB โ”‚ โ”‚ โ”‚ +โ”‚ - sageFlow โ”‚ โ–ผ โ”‚ +โ”‚ - neuromem โ”‚ ReportSink โ”‚ +โ”‚ - sageTSDB โ”‚ (md/json/console) โ”‚ +โ”‚ - SAGE-docs โ”‚ โ”‚ +โ”‚ - SAGE-data โ”‚ โ”‚ +โ”‚ - libamm โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +โ”‚ DiaryEntrySource โ”€โ”€โ”˜ โ–ผ โ”‚ โ”‚ LLMReportGenerator (Optional) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ ReportSink โ”‚ โ”‚ (Console / +Markdown / JSON) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +```` + +### ๆ ธๅฟƒ็ป„ไปถ + +| ็ป„ไปถ | ็ฑปๅž‹ | ๅŠŸ่ƒฝ | +|------|------|------| +| `GitHubDataSource` | BatchFunction | ไปŽ GitHub ่Žทๅ– commits ๅ’Œ PRs | +| `DiaryEntrySource` | BatchFunction | ๅŠ ่ฝฝๆœฌๅœฐๆ—ฅ่ฎฐๆ–‡ไปถ | +| `ContributorAggregator` | MapFunction | ๆŒ‰่ดก็Œฎ่€…่šๅˆๆ•ฐๆฎ | +| `LLMReportGenerator` | MapFunction | ไฝฟ็”จ LLM ็”Ÿๆˆๆ‘˜่ฆ | +| `ReportSink` | SinkFunction | ่พ“ๅ‡บๆœ€็ปˆๆŠฅๅ‘Š | + +## ่พ“ๅ‡บ็คบไพ‹ + +### Markdown ๆ ผๅผ + +```markdown +# Weekly Work Report + +**Period:** 2024-01-08 ~ 2024-01-15 +**Repositories:** intellistream/SAGE + +## Summary + +- Total Commits: 42 +- Total PRs: 8 (6 merged) + +## Contributors + +### developer1 + +- **Commits:** 15 +- **PRs:** 3 (merged: 2) +- **Lines Changed:** +1500 / -300 + +**AI Summary:** +> developer1 ๆœฌๅ‘จไธป่ฆๅฎŒๆˆไบ†ๅ‘จๆŠฅ็”Ÿๆˆๅ™จ็š„ๆ ธๅฟƒๅŠŸ่ƒฝๅฎž็Žฐ... +```` + +### JSON ๆ ผๅผ + +```json +{ + "start_date": "2024-01-08", + "end_date": "2024-01-15", + "repos": ["intellistream/SAGE"], + "summary": { + "total_commits": 42, + "total_prs": 8, + "total_merged_prs": 6 + }, + "contributors": [ + { + "username": "developer1", + "stats": { + "commits": 15, + "prs": 3, + "merged_prs": 2 + }, + "llm_summary": "..." + } + ] +} +``` + +## CI/CD ้›†ๆˆ + +### GitHub Actions (Weekly Schedule) + +ๅ‚่ง `.github/workflows/weekly-report.yml`: + +```yaml +name: Weekly Report Generator +on: + schedule: + - cron: '0 9 * * 1' # ๆฏๅ‘จไธ€ 9:00 UTC + workflow_dispatch: + +jobs: + generate-report: + runs-on: [self-hosted, A6000] + steps: + - uses: actions/checkout@v4 + - name: Generate Report + run: | + python -m sage.apps.work_report_generator.pipeline \ + --repos intellistream/SAGE \ + --days 7 \ + --format markdown \ + --output reports/weekly_$(date +%Y%m%d).md + env: + GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} +``` + +## ๆ—ฅ่ฎฐๆ ผๅผ + +### JSON ๆ ผๅผ + +```json +[ + { + "date": "2024-01-15", + "author": "developer1", + "content": "ไปŠๅคฉๅฎŒๆˆไบ†ๅ‘จๆŠฅ็”Ÿๆˆๅ™จ็š„ๅผ€ๅ‘...", + "tags": ["development", "feature"], + "category": "work" + } +] +``` + +### Markdown ๆ ผๅผ + +ๆ–‡ไปถๅๆ ผๅผ: `YYYY-MM-DD.md` + +```markdown +# 2024-01-15 ๅทฅไฝœๆ—ฅๅฟ— + +ไปŠๅคฉไธป่ฆๅฎŒๆˆไบ†ไปฅไธ‹ๅทฅไฝœ๏ผš +- ๅฎž็Žฐไบ† GitHub ๆ•ฐๆฎ่Žทๅ–ๆจกๅ— +- ๆทปๅŠ ไบ† LLM ๆ‘˜่ฆ็”ŸๆˆๅŠŸ่ƒฝ +``` + +## ๅผ€ๅ‘ + +### ่ฟ่กŒๆต‹่ฏ• + +```bash +pytest packages/sage-apps/tests/unit/test_work_report_generator/ -v +``` + +### ไปฃ็ ็ป“ๆž„ + +``` +packages/sage-apps/src/sage/apps/work_report_generator/ +โ”œโ”€โ”€ __init__.py # ๆจกๅ—ๅ…ฅๅฃ +โ”œโ”€โ”€ models.py # ๆ•ฐๆฎๆจกๅž‹ +โ”œโ”€โ”€ operators.py # SAGE ็ฎ—ๅญ +โ”œโ”€โ”€ pipeline.py # ็ฎก้“ๅฎž็Žฐ +โ””โ”€โ”€ README.md # ๆ–‡ๆกฃ +``` + +## ่ฎธๅฏ่ฏ + +Apache License 2.0 diff --git a/apps/src/sage/apps/work_report_generator/__init__.py b/apps/src/sage/apps/work_report_generator/__init__.py new file mode 100644 index 0000000..ebab68f --- /dev/null +++ b/apps/src/sage/apps/work_report_generator/__init__.py @@ -0,0 +1,20 @@ +""" +Work Report Generator - Weekly/Daily report generator based on SAGE framework. + +This application generates work reports by combining: +- GitHub commit and PR data from specified repositories +- Optional diary entries for personal notes +- LLM-powered summarization and formatting + +Example: + >>> from sage.apps.work_report_generator import run_work_report_pipeline + >>> run_work_report_pipeline( + ... repos=["intellistream/SAGE"], + ... days=7, + ... output_format="markdown" + ... ) +""" + +from .pipeline import run_work_report_pipeline + +__all__ = ["run_work_report_pipeline"] diff --git a/apps/src/sage/apps/work_report_generator/models.py b/apps/src/sage/apps/work_report_generator/models.py new file mode 100644 index 0000000..2868ecb --- /dev/null +++ b/apps/src/sage/apps/work_report_generator/models.py @@ -0,0 +1,292 @@ +""" +Data models for Work Report Generator. + +Contains dataclasses for commits, PRs, diary entries, and reports. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + + +class ReportPeriod(Enum): + """Report period types.""" + + WEEKLY = "weekly" + MONTHLY = "monthly" + QUARTERLY = "quarterly" + YEARLY = "yearly" + + @property + def display_name(self) -> str: + """Get display name for the period.""" + names = { + ReportPeriod.WEEKLY: "ๅ‘จๆŠฅ", + ReportPeriod.MONTHLY: "ๆœˆๆŠฅ", + ReportPeriod.QUARTERLY: "ๅญฃๆŠฅ", + ReportPeriod.YEARLY: "ๅนดๆŠฅ", + } + return names.get(self, self.value) + + @property + def english_name(self) -> str: + """Get English name for the period.""" + names = { + ReportPeriod.WEEKLY: "Weekly Report", + ReportPeriod.MONTHLY: "Monthly Report", + ReportPeriod.QUARTERLY: "Quarterly Report", + ReportPeriod.YEARLY: "Yearly Report", + } + return names.get(self, self.value.title()) + + +@dataclass +class GitHubCommit: + """Represents a GitHub commit.""" + + sha: str + message: str + author: str + author_email: str + committed_date: str + repo: str + url: str + additions: int = 0 + deletions: int = 0 + changed_files: int = 0 + # For Copilot commits: the user who assigned/owns the work + assigned_by: str | None = None + + @classmethod + def from_graphql(cls, data: dict[str, Any], repo: str) -> GitHubCommit: + """Create from GraphQL response.""" + author = data.get("author", {}) or {} + user = author.get("user", {}) or {} + author_login = user.get("login", author.get("name", "Unknown")) + + # Try to find the assigning user for Copilot commits + assigned_by = None + if cls._is_copilot_author(author_login): + assigned_by = cls._extract_assigned_by(data) + + return cls( + sha=data.get("oid", ""), + message=data.get("message", "").split("\n")[0], # First line only + author=author_login, + author_email=author.get("email", ""), + committed_date=data.get("committedDate", ""), + repo=repo, + url=data.get("url", ""), + additions=data.get("additions", 0), + deletions=data.get("deletions", 0), + changed_files=data.get("changedFiles", 0), + assigned_by=assigned_by, + ) + + @staticmethod + def _is_copilot_author(author: str) -> bool: + """Check if the author is Copilot.""" + copilot_names = {"copilot", "copilot-swe-agent", "github-copilot"} + return author.lower() in copilot_names + + @staticmethod + def _extract_assigned_by(data: dict[str, Any]) -> str | None: + """Extract the user who assigned/owns the Copilot work.""" + # Try from associated PR author or assignees + associated_prs = data.get("associatedPullRequests", {}) or {} + pr_nodes = associated_prs.get("nodes", []) + + if pr_nodes: + pr = pr_nodes[0] + # First try assignees + assignees = pr.get("assignees", {}) or {} + assignee_nodes = assignees.get("nodes", []) + if assignee_nodes: + return assignee_nodes[0].get("login") + + # Then try PR author + pr_author = pr.get("author", {}) or {} + pr_author_login = pr_author.get("login") + if pr_author_login and not GitHubCommit._is_copilot_author(pr_author_login): + return pr_author_login + + # Try from committer (different from author) + committer = data.get("committer", {}) or {} + committer_user = committer.get("user", {}) or {} + committer_login = committer_user.get("login") + if committer_login and not GitHubCommit._is_copilot_author(committer_login): + return committer_login + + return None + + +@dataclass +class GitHubPullRequest: + """Represents a GitHub Pull Request.""" + + number: int + title: str + author: str + state: str # OPEN, CLOSED, MERGED + created_at: str + merged_at: str | None + closed_at: str | None + repo: str + url: str + additions: int = 0 + deletions: int = 0 + changed_files: int = 0 + labels: list[str] = field(default_factory=list) + reviewers: list[str] = field(default_factory=list) + # For Copilot PRs: the user who assigned/owns the work + assigned_by: str | None = None + + @classmethod + def from_graphql(cls, data: dict[str, Any], repo: str) -> GitHubPullRequest: + """Create from GraphQL response.""" + author_data = data.get("author", {}) or {} + author_login = author_data.get("login", "Unknown") + + labels = [ + label.get("name", "") for label in (data.get("labels", {}) or {}).get("nodes", []) + ] + reviewers = [ + reviewer.get("login", "") + for reviewer in (data.get("reviewRequests", {}) or {}).get("nodes", []) + if reviewer.get("requestedReviewer") + ] + + # Extract assignees for Copilot attribution + assigned_by = None + if cls._is_copilot_author(author_login): + assigned_by = cls._extract_assigned_by(data, author_login) + + return cls( + number=data.get("number", 0), + title=data.get("title", ""), + author=author_login, + state=data.get("state", "UNKNOWN"), + created_at=data.get("createdAt", ""), + merged_at=data.get("mergedAt"), + closed_at=data.get("closedAt"), + repo=repo, + url=data.get("url", ""), + additions=data.get("additions", 0), + deletions=data.get("deletions", 0), + changed_files=data.get("changedFiles", 0), + labels=labels, + reviewers=reviewers, + assigned_by=assigned_by, + ) + + @staticmethod + def _is_copilot_author(author: str) -> bool: + """Check if the author is Copilot.""" + copilot_names = {"copilot", "copilot-swe-agent", "github-copilot"} + return author.lower() in copilot_names + + @staticmethod + def _extract_assigned_by(data: dict[str, Any], author_login: str) -> str | None: + """Extract the user who assigned/owns the Copilot PR.""" + # Try from assignees first + assignees = data.get("assignees", {}) or {} + assignee_nodes = assignees.get("nodes", []) + for assignee in assignee_nodes: + login = assignee.get("login") + if login and not GitHubPullRequest._is_copilot_author(login): + return login + + # Try from reviewers + review_requests = data.get("reviewRequests", {}) or {} + for req in review_requests.get("nodes", []): + reviewer = req.get("requestedReviewer", {}) or {} + login = reviewer.get("login") + if login and not GitHubPullRequest._is_copilot_author(login): + return login + + return None + + +@dataclass +class DiaryEntry: + """Represents a diary/note entry.""" + + date: str + author: str + content: str + tags: list[str] = field(default_factory=list) + category: str = "general" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DiaryEntry: + """Create from dictionary.""" + return cls( + date=data.get("date", ""), + author=data.get("author", "Unknown"), + content=data.get("content", ""), + tags=data.get("tags", []), + category=data.get("category", "general"), + ) + + +@dataclass +class ContributorSummary: + """Summary of contributions for a single contributor.""" + + username: str + commits: list[GitHubCommit] = field(default_factory=list) + pull_requests: list[GitHubPullRequest] = field(default_factory=list) + diary_entries: list[DiaryEntry] = field(default_factory=list) + + # Aggregated stats + total_commits: int = 0 + total_prs: int = 0 + merged_prs: int = 0 + total_additions: int = 0 + total_deletions: int = 0 + total_changed_files: int = 0 + + def calculate_stats(self) -> None: + """Calculate aggregated statistics.""" + self.total_commits = len(self.commits) + self.total_prs = len(self.pull_requests) + self.merged_prs = sum(1 for pr in self.pull_requests if pr.state == "MERGED") + self.total_additions = sum(c.additions for c in self.commits) + sum( + pr.additions for pr in self.pull_requests + ) + self.total_deletions = sum(c.deletions for c in self.commits) + sum( + pr.deletions for pr in self.pull_requests + ) + self.total_changed_files = sum(c.changed_files for c in self.commits) + + +@dataclass +class PeriodReport: + """Report for a specific time period (weekly/monthly/quarterly/yearly).""" + + start_date: str + end_date: str + repos: list[str] + period: ReportPeriod = ReportPeriod.WEEKLY + contributors: list[ContributorSummary] = field(default_factory=list) + generated_at: str = "" + llm_summary: str = "" + + # Overall stats + total_commits: int = 0 + total_prs: int = 0 + total_merged_prs: int = 0 + + def calculate_overall_stats(self) -> None: + """Calculate overall statistics.""" + self.total_commits = sum(c.total_commits for c in self.contributors) + self.total_prs = sum(c.total_prs for c in self.contributors) + self.total_merged_prs = sum(c.merged_prs for c in self.contributors) + self.generated_at = datetime.now().isoformat() + + +# Alias for backward compatibility +WeeklyReport = PeriodReport diff --git a/apps/src/sage/apps/work_report_generator/operators.py b/apps/src/sage/apps/work_report_generator/operators.py new file mode 100644 index 0000000..745e7a7 --- /dev/null +++ b/apps/src/sage/apps/work_report_generator/operators.py @@ -0,0 +1,1507 @@ +""" +SAGE Operators for Work Report Generator. + +Implements source, map, and sink operators for the work report pipeline. +""" + +from __future__ import annotations + +import json +import os +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import requests + +from sage.foundation import BatchFunction, MapFunction, SinkFunction + +from .models import ( + ContributorSummary, + DiaryEntry, + GitHubCommit, + GitHubPullRequest, + ReportPeriod, + WeeklyReport, +) + + +def calculate_period_dates( + period: ReportPeriod, reference_date: datetime | None = None +) -> tuple[datetime, datetime]: + """ + Calculate start and end dates for a given report period. + + Args: + period: The report period type (weekly, monthly, quarterly, yearly). + reference_date: The reference date to calculate from. Defaults to now. + + Returns: + Tuple of (start_date, end_date) for the period. + """ + if reference_date is None: + reference_date = datetime.now() + + if period == ReportPeriod.WEEKLY: + # Last 7 days + end_date = reference_date + start_date = end_date - timedelta(days=7) + elif period == ReportPeriod.MONTHLY: + # Last calendar month or last 30 days + end_date = reference_date + # Go to first day of current month, then back one day to get last month + first_of_month = reference_date.replace(day=1) + start_date = (first_of_month - timedelta(days=1)).replace(day=1) + end_date = first_of_month - timedelta(days=1) + # If we're past the 7th, use last 30 days instead for more recent data + if reference_date.day > 7: + end_date = reference_date + start_date = end_date - timedelta(days=30) + elif period == ReportPeriod.QUARTERLY: + # Last quarter (3 months / ~90 days) + end_date = reference_date + start_date = end_date - timedelta(days=90) + elif period == ReportPeriod.YEARLY: + # Last year (365 days) + end_date = reference_date + start_date = end_date - timedelta(days=365) + else: + # Default to weekly + end_date = reference_date + start_date = end_date - timedelta(days=7) + + return start_date, end_date + + +def get_period_days(period: ReportPeriod) -> int: + """Get the number of days for a report period.""" + days_map = { + ReportPeriod.WEEKLY: 7, + ReportPeriod.MONTHLY: 30, + ReportPeriod.QUARTERLY: 90, + ReportPeriod.YEARLY: 365, + } + return days_map.get(period, 7) + + +class GitHubDataSource(BatchFunction): + """ + BatchFunction source that fetches commits and PRs from GitHub. + + Uses GitHub GraphQL API to fetch contribution data for specified repositories. + Supports fetching from specific branches (e.g., main-dev) and submodule repos. + """ + + # SAGE main repository and all submodule repositories + SAGE_REPOS = [ + "intellistream/SAGE", + "intellistream/SAGE-Pub", # docs-public submodule + "intellistream/sageData", # benchmark data submodule + "intellistream/sageLLM", # sageLLM submodule + "intellistream/LibAMM", # libamm submodule + "intellistream/sageVDB", # sageVDB submodule + "intellistream/sageFlow", # sageFlow submodule + "intellistream/neuromem", # neuromem submodule + "intellistream/sageTSDB", # sageTSDB submodule + ] + + def __init__( + self, + repos: list[str] | None = None, + days: int | None = None, + period: ReportPeriod | str = ReportPeriod.WEEKLY, + branch: str = "main-dev", + github_token: str | None = None, + include_submodules: bool = True, + **kwargs, + ) -> None: + """ + Initialize GitHub data source. + + Args: + repos: List of repositories in "owner/repo" format. + If None, defaults to SAGE_REPOS (main + submodules). + days: Number of days to look back for contributions (overrides period). + period: Report period type (weekly/monthly/quarterly/yearly). + branch: Branch name to fetch commits from. Default "main-dev". + github_token: GitHub personal access token. If not provided, + will try GITHUB_TOKEN or GIT_TOKEN environment variables. + include_submodules: If True and repos is None, include all SAGE submodules. + """ + super().__init__(**kwargs) + + # Use SAGE repos by default if none specified + if repos is None: + self.repos = self.SAGE_REPOS if include_submodules else ["intellistream/SAGE"] + else: + self.repos = repos + + # Handle period parameter + if isinstance(period, str): + period = ReportPeriod(period.lower()) + self.period = period + + # Days can override period + if days is not None: + self.days = days + else: + self.days = get_period_days(period) + + self.branch = branch + self.github_token = github_token or os.environ.get( + "GITHUB_TOKEN", os.environ.get("GIT_TOKEN", "") + ) + self.graphql_url = "https://api.github.com/graphql" + self.headers = { + "Authorization": f"Bearer {self.github_token}", + "Content-Type": "application/json", + } + + # Data storage + self.commits: list[GitHubCommit] = [] + self.pull_requests: list[GitHubPullRequest] = [] + self.current_index = 0 + self.fetched = False + + # Calculate date range based on period or days + self.start_date, self.end_date = calculate_period_dates(period) + + def _execute_graphql( + self, query: str, variables: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Execute a GraphQL query.""" + payload = {"query": query} + if variables: + payload["variables"] = variables + + try: + response = requests.post( + self.graphql_url, + json=payload, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + self.logger.error(f"GraphQL request failed: {e}") + return {"errors": [str(e)]} + + def _fetch_commits(self, owner: str, repo: str) -> list[GitHubCommit]: + """Fetch commits for a repository within the date range from specific branch.""" + commits = [] + since = self.start_date.isoformat() + + # Query that supports fetching from a specific branch (ref) + # Also fetches associated PR info to determine who assigned Copilot + query = """ + query($owner: String!, $repo: String!, $branch: String!, $since: GitTimestamp!, $after: String) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branch) { + target { + ... on Commit { + history(since: $since, first: 100, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + oid + message + committedDate + url + additions + deletions + changedFiles + author { + name + email + user { + login + } + } + committer { + name + email + user { + login + } + } + associatedPullRequests(first: 1) { + nodes { + author { + login + } + assignees(first: 5) { + nodes { + login + } + } + } + } + } + } + } + } + } + } + } + """ + + after = None + repo_full = f"{owner}/{repo}" + + while True: + variables = { + "owner": owner, + "repo": repo, + "branch": f"refs/heads/{self.branch}", + "since": since, + "after": after, + } + result = self._execute_graphql(query, variables) + + if "errors" in result: + self.logger.error( + f"Error fetching commits from {repo_full}@{self.branch}: {result['errors']}" + ) + # Try fallback to default branch + fallback_commits = self._fetch_commits_default_branch(owner, repo) + return fallback_commits + + data = result.get("data", {}).get("repository", {}).get("ref", {}) + + if not data: + self.logger.warning( + f"Branch '{self.branch}' not found in {repo_full}. Trying default branch..." + ) + return self._fetch_commits_default_branch(owner, repo) + + history = data.get("target", {}).get("history", {}) + nodes = history.get("nodes", []) + + for node in nodes: + commit = GitHubCommit.from_graphql(node, repo_full) + commits.append(commit) + + page_info = history.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + time.sleep(0.2) # Rate limiting + + return commits + + def _fetch_commits_default_branch(self, owner: str, repo: str) -> list[GitHubCommit]: + """Fallback: Fetch commits from the default branch if specified branch not found.""" + commits = [] + since = self.start_date.isoformat() + + query = """ + query($owner: String!, $repo: String!, $since: GitTimestamp!, $after: String) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + target { + ... on Commit { + history(since: $since, first: 100, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + oid + message + committedDate + url + additions + deletions + changedFiles + author { + name + email + user { + login + } + } + } + } + } + } + } + } + } + """ + + after = None + repo_full = f"{owner}/{repo}" + branch_name = None + + while True: + variables = { + "owner": owner, + "repo": repo, + "since": since, + "after": after, + } + result = self._execute_graphql(query, variables) + + if "errors" in result: + self.logger.error(f"Error fetching commits: {result['errors']}") + break + + data = result.get("data", {}).get("repository", {}).get("defaultBranchRef", {}) + + if not branch_name and data: + branch_name = data.get("name", "unknown") + self.logger.info(f" Using default branch: {branch_name}") + + history = data.get("target", {}).get("history", {}) + nodes = history.get("nodes", []) + + for node in nodes: + commit = GitHubCommit.from_graphql(node, repo_full) + commits.append(commit) + + page_info = history.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + time.sleep(0.2) # Rate limiting + + return commits + + def _fetch_pull_requests(self, owner: str, repo: str) -> list[GitHubPullRequest]: + """Fetch pull requests for a repository within the date range.""" + pull_requests = [] + + query = """ + query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + state + createdAt + mergedAt + closedAt + url + additions + deletions + changedFiles + author { + login + } + assignees(first: 5) { + nodes { + login + } + } + labels(first: 10) { + nodes { + name + } + } + reviewRequests(first: 10) { + nodes { + requestedReviewer { + ... on User { + login + } + } + } + } + } + } + } + } + """ + + after = None + repo_full = f"{owner}/{repo}" + + while True: + variables = { + "owner": owner, + "repo": repo, + "after": after, + } + result = self._execute_graphql(query, variables) + + if "errors" in result: + self.logger.error(f"Error fetching PRs: {result['errors']}") + break + + data = result.get("data", {}).get("repository", {}).get("pullRequests", {}) + + nodes = data.get("nodes", []) + for node in nodes: + # Filter by date range + created_at = node.get("createdAt", "") + if created_at: + created_date = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + if created_date.replace(tzinfo=None) < self.start_date: + # PRs are sorted by created_at DESC, so we can stop + break + + pr = GitHubPullRequest.from_graphql(node, repo_full) + pull_requests.append(pr) + + page_info = data.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + + # Check if we've gone past our date range + if nodes: + last_created = nodes[-1].get("createdAt", "") + if last_created: + last_date = datetime.fromisoformat(last_created.replace("Z", "+00:00")) + if last_date.replace(tzinfo=None) < self.start_date: + break + + after = page_info.get("endCursor") + time.sleep(0.2) # Rate limiting + + return pull_requests + + def _fetch_all_data(self) -> None: + """Fetch all commits and PRs from all repositories.""" + if not self.github_token: + self.logger.warning("No GitHub token provided. Using mock data.") + self._use_mock_data() + return + + self.logger.info(f"Target branch: {self.branch}") + self.logger.info(f"Repositories to fetch: {len(self.repos)}") + + for repo in self.repos: + parts = repo.split("/") + if len(parts) != 2: + self.logger.warning(f"Invalid repo format: {repo}") + continue + + owner, repo_name = parts + self.logger.info(f"Fetching data from {repo} @ {self.branch}...") + + # Fetch commits from specified branch + commits = self._fetch_commits(owner, repo_name) + self.commits.extend(commits) + self.logger.info(f" Found {len(commits)} commits") + + # Fetch PRs (PRs are not branch-specific in the same way) + prs = self._fetch_pull_requests(owner, repo_name) + self.pull_requests.extend(prs) + self.logger.info(f" Found {len(prs)} pull requests") + + def _use_mock_data(self) -> None: + """Generate mock data for testing.""" + mock_commits = [ + { + "oid": "abc123", + "message": "feat: Add weekly report generator", + "committedDate": datetime.now().isoformat(), + "url": "https://github.com/intellistream/SAGE/commit/abc123", + "additions": 500, + "deletions": 100, + "changedFiles": 10, + "author": { + "name": "Developer One", + "email": "dev1@example.com", + "user": {"login": "dev1"}, + }, + }, + { + "oid": "def456", + "message": "fix: Resolve pipeline issue", + "committedDate": (datetime.now() - timedelta(days=1)).isoformat(), + "url": "https://github.com/intellistream/SAGE/commit/def456", + "additions": 50, + "deletions": 30, + "changedFiles": 3, + "author": { + "name": "Developer Two", + "email": "dev2@example.com", + "user": {"login": "dev2"}, + }, + }, + ] + + mock_prs = [ + { + "number": 100, + "title": "feat: Weekly report generator implementation", + "state": "MERGED", + "createdAt": datetime.now().isoformat(), + "mergedAt": datetime.now().isoformat(), + "closedAt": None, + "url": "https://github.com/intellistream/SAGE/pull/100", + "additions": 1000, + "deletions": 200, + "changedFiles": 15, + "author": {"login": "dev1"}, + "labels": {"nodes": [{"name": "feature"}]}, + "reviewRequests": {"nodes": []}, + }, + ] + + for commit_data in mock_commits: + self.commits.append(GitHubCommit.from_graphql(commit_data, "intellistream/SAGE")) + + for pr_data in mock_prs: + self.pull_requests.append(GitHubPullRequest.from_graphql(pr_data, "intellistream/SAGE")) + + def execute(self) -> dict[str, Any] | None: + """Execute batch function to emit data packets.""" + if not self.fetched: + self.logger.info(f"Fetching GitHub data for past {self.days} days...") + self._fetch_all_data() + self.fetched = True + self.logger.info(f"Fetched {len(self.commits)} commits, {len(self.pull_requests)} PRs") + + # Emit commits first, then PRs + total_items = len(self.commits) + len(self.pull_requests) + if self.current_index < len(self.commits): + commit = self.commits[self.current_index] + self.current_index += 1 + return { + "type": "commit", + "data": commit, + "repo": commit.repo, + "author": commit.author, + } + elif self.current_index < total_items: + pr_index = self.current_index - len(self.commits) + pr = self.pull_requests[pr_index] + self.current_index += 1 + return { + "type": "pull_request", + "data": pr, + "repo": pr.repo, + "author": pr.author, + } + + return None + + +class DiaryEntrySource(BatchFunction): + """ + BatchFunction source that loads diary entries from files. + + Supports JSON and Markdown formats. + """ + + def __init__( + self, + diary_path: str | Path | None = None, + days: int | None = None, + period: ReportPeriod | str = ReportPeriod.WEEKLY, + **kwargs, + ) -> None: + """ + Initialize diary entry source. + + Args: + diary_path: Path to diary directory or file. + days: Number of days to look back (overrides period). + period: Report period type (weekly/monthly/quarterly/yearly). + """ + super().__init__(**kwargs) + self.diary_path = Path(diary_path) if diary_path else None + + # Handle period parameter + if isinstance(period, str): + period = ReportPeriod(period.lower()) + self.period = period + + # Days can override period + if days is not None: + self.days = days + else: + self.days = get_period_days(period) + + self.entries: list[DiaryEntry] = [] + self.current_index = 0 + self.fetched = False + + # Calculate date range based on period + self.start_date, self.end_date = calculate_period_dates(period) + + def _load_entries(self) -> None: + """Load diary entries from the specified path.""" + if not self.diary_path or not self.diary_path.exists(): + self.logger.info("No diary path specified or path doesn't exist") + return + + if self.diary_path.is_file(): + self._load_file(self.diary_path) + elif self.diary_path.is_dir(): + # Load all JSON and MD files + for file_path in self.diary_path.glob("**/*.json"): + self._load_file(file_path) + for file_path in self.diary_path.glob("**/*.md"): + self._load_markdown_file(file_path) + + def _load_file(self, file_path: Path) -> None: + """Load entries from a JSON file.""" + try: + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + + if isinstance(data, list): + for item in data: + entry = DiaryEntry.from_dict(item) + if self._is_in_date_range(entry.date): + self.entries.append(entry) + elif isinstance(data, dict): + entry = DiaryEntry.from_dict(data) + if self._is_in_date_range(entry.date): + self.entries.append(entry) + + except Exception as e: + self.logger.warning(f"Error loading {file_path}: {e}") + + def _load_markdown_file(self, file_path: Path) -> None: + """Load a single markdown file as a diary entry.""" + try: + content = file_path.read_text(encoding="utf-8") + + # Extract date from filename (e.g., 2024-01-15.md) + date_str = file_path.stem + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + date_str = datetime.now().strftime("%Y-%m-%d") + + if self._is_in_date_range(date_str): + entry = DiaryEntry( + date=date_str, + author="Unknown", + content=content, + category="diary", + ) + self.entries.append(entry) + + except Exception as e: + self.logger.warning(f"Error loading markdown {file_path}: {e}") + + def _is_in_date_range(self, date_str: str) -> bool: + """Check if a date string is within the configured range.""" + try: + date = datetime.strptime(date_str[:10], "%Y-%m-%d") + return self.start_date <= date <= self.end_date + except ValueError: + return True # Include if can't parse + + def execute(self) -> dict[str, Any] | None: + """Execute batch function to emit diary entries.""" + if not self.fetched: + self._load_entries() + self.fetched = True + self.logger.info(f"Loaded {len(self.entries)} diary entries") + + if self.current_index < len(self.entries): + entry = self.entries[self.current_index] + self.current_index += 1 + return { + "type": "diary_entry", + "data": entry, + "author": entry.author, + } + + return None + + +class ContributorAggregator(MapFunction): + """ + MapFunction that aggregates data by contributor. + + Collects commits, PRs, and diary entries for each contributor. + """ + + # Class-level storage for aggregation across all instances + _contributors: dict[str, ContributorSummary] = {} + + # Username aliases: map alternative names to canonical names + # Key: lowercase alias, Value: canonical name + USERNAME_ALIASES: dict[str, str] = { + # ShuhaoZhangTony variants + "shuhao zhang": "ShuhaoZhangTony", + "shuhaozhangtony": "ShuhaoZhangTony", + "shuhaozhang": "ShuhaoZhangTony", + # Copilot variants - these will be reassigned to their assigners + "copilot": "Copilot", + "copilot-swe-agent": "Copilot", + "github-copilot": "Copilot", + } + + # Copilot usernames (lowercase) + COPILOT_USERS: set[str] = {"copilot", "copilot-swe-agent", "github-copilot"} + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + @classmethod + def normalize_username(cls, username: str) -> str: + """Normalize username to canonical form.""" + if not username: + return "Unknown" + # Check if lowercase version matches any alias + lower_name = username.lower().strip() + return cls.USERNAME_ALIASES.get(lower_name, username) + + @classmethod + def is_copilot_user(cls, username: str) -> bool: + """Check if the username is a Copilot variant.""" + return username.lower().strip() in cls.COPILOT_USERS + + def _resolve_author( + self, raw_author: str, item: GitHubCommit | GitHubPullRequest | DiaryEntry | None + ) -> tuple[str, bool]: + """ + Resolve the final author for contribution attribution. + + Returns: + tuple: (final_author, is_copilot_assisted) + """ + normalized = self.normalize_username(raw_author) + + # Check if this is a Copilot commit with an assigned user + if isinstance(item, GitHubCommit) and self.is_copilot_user(raw_author): + if item.assigned_by: + # Attribute to the assigning user, but mark as Copilot-assisted + final_author = self.normalize_username(item.assigned_by) + return final_author, True + + # Check if this is a Copilot PR with an assigned user + if isinstance(item, GitHubPullRequest) and self.is_copilot_user(raw_author): + if item.assigned_by: + # Attribute to the assigning user, but mark as Copilot-assisted + final_author = self.normalize_username(item.assigned_by) + return final_author, True + + return normalized, False + + def execute(self, data: dict[str, Any]) -> dict[str, Any] | None: + """Aggregate data by contributor.""" + # Skip non-dict data (e.g., StopSignal) + if not isinstance(data, dict): + return None + + raw_author = data.get("author", "Unknown") + data_type = data.get("type") + item = data.get("data") + + # Resolve author (handle Copilot attribution) + author, is_copilot_assisted = self._resolve_author(raw_author, item) + + if author not in ContributorAggregator._contributors: + ContributorAggregator._contributors[author] = ContributorSummary(username=author) + + contributor = ContributorAggregator._contributors[author] + + if data_type == "commit" and isinstance(item, GitHubCommit): + contributor.commits.append(item) + if is_copilot_assisted: + # Track Copilot-assisted commits separately + if not hasattr(contributor, "copilot_assisted_commits"): + contributor.copilot_assisted_commits = 0 + contributor.copilot_assisted_commits += 1 + elif data_type == "pull_request" and isinstance(item, GitHubPullRequest): + contributor.pull_requests.append(item) + if is_copilot_assisted: + # Track Copilot-assisted PRs separately + if not hasattr(contributor, "copilot_assisted_prs"): + contributor.copilot_assisted_prs = 0 + contributor.copilot_assisted_prs += 1 + elif data_type == "diary_entry" and isinstance(item, DiaryEntry): + contributor.diary_entries.append(item) + + # Return the updated contributor summary + contributor.calculate_stats() + return { + "type": "contributor_update", + "author": author, + "contributor": contributor, + } + + @classmethod + def get_all_contributors(cls) -> dict[str, ContributorSummary]: + """Get all aggregated contributors.""" + return cls._contributors + + @classmethod + def reset(cls) -> None: + """Reset the aggregator state.""" + cls._contributors = {} + + +class LLMReportGenerator(MapFunction): + """ + MapFunction that generates LLM-powered summaries for contributors. + + Uses UnifiedInferenceClient to generate natural language summaries. + """ + + def __init__( + self, + language: str = "zh", + **kwargs, + ) -> None: + """ + Initialize LLM report generator. + + Args: + language: Output language ("zh" for Chinese, "en" for English). + """ + super().__init__(**kwargs) + self.language = language + self._client = None + self._processed_authors: set[str] = set() + + def _get_client(self): + """Lazy initialization of LLM client.""" + if self._client is None: + try: + from sage.llm import UnifiedInferenceClient + + self._client = UnifiedInferenceClient.create() + except Exception as e: + self.logger.warning(f"Failed to initialize LLM client: {e}") + self._client = None + return self._client + + def _generate_summary(self, contributor: ContributorSummary) -> str: + """Generate a summary for a contributor using LLM.""" + client = self._get_client() + if client is None: + return self._generate_simple_summary(contributor) + + # Build context for LLM + commits_text = "\n".join( + f"- {c.message} ({c.additions}+ / {c.deletions}-)" + for c in contributor.commits[:10] # Limit to recent 10 + ) + + prs_text = "\n".join(f"- [{pr.state}] {pr.title}" for pr in contributor.pull_requests[:5]) + + diary_text = "\n".join( + f"- [{e.date}] {e.content[:100]}..." for e in contributor.diary_entries[:3] + ) + + if self.language == "zh": + prompt = f"""่ฏทไธบไปฅไธ‹่ดก็Œฎ่€…็”Ÿๆˆไธ€ไปฝ็ฎ€ๆด็š„ๅ‘จๆŠฅๆ€ป็ป“๏ผš + +่ดก็Œฎ่€…๏ผš{contributor.username} + +ๆœฌๅ‘จๆไบค่ฎฐๅฝ•๏ผš +{commits_text or "ๆ— ๆไบค่ฎฐๅฝ•"} + +Pull Requests๏ผš +{prs_text or "ๆ— PR่ฎฐๅฝ•"} + +ไธชไบบๆ—ฅๅฟ—๏ผš +{diary_text or "ๆ— ๆ—ฅๅฟ—"} + +็ปŸ่ฎกๆ•ฐๆฎ๏ผš +- ๆไบคๆฌกๆ•ฐ๏ผš{contributor.total_commits} +- PRๆ•ฐ้‡๏ผš{contributor.total_prs}๏ผˆๅทฒๅˆๅนถ๏ผš{contributor.merged_prs}๏ผ‰ +- ไปฃ็ ๅ˜ๆ›ด๏ผš+{contributor.total_additions} / -{contributor.total_deletions} + +่ฏท็”Ÿๆˆไธ€ไปฝ100-200ๅญ—็š„ๅทฅไฝœๆ€ป็ป“๏ผŒ็ชๅ‡บไธป่ฆ่ดก็Œฎๅ’Œๆˆๆžœใ€‚""" + else: + prompt = f"""Generate a brief weekly report summary for the following contributor: + +Contributor: {contributor.username} + +Commits this week: +{commits_text or "No commits"} + +Pull Requests: +{prs_text or "No PRs"} + +Personal notes: +{diary_text or "No notes"} + +Statistics: +- Commits: {contributor.total_commits} +- PRs: {contributor.total_prs} (Merged: {contributor.merged_prs}) +- Lines changed: +{contributor.total_additions} / -{contributor.total_deletions} + +Generate a 100-200 word work summary highlighting key contributions.""" + + try: + messages = [{"role": "user", "content": prompt}] + response = client.chat(messages) + if hasattr(response, "content"): + return response.content + return str(response) + except Exception as e: + self.logger.warning(f"LLM generation failed: {e}") + return self._generate_simple_summary(contributor) + + def _generate_simple_summary(self, contributor: ContributorSummary) -> str: + """Generate a simple summary without LLM.""" + if self.language == "zh": + return ( + f"{contributor.username} ๆœฌๅ‘จ่ดก็Œฎไบ† {contributor.total_commits} ๆฌกๆไบค๏ผŒ" + f"ๆไบคไบ† {contributor.total_prs} ไธชPR๏ผˆๅทฒๅˆๅนถ {contributor.merged_prs} ไธช๏ผ‰๏ผŒ" + f"ไปฃ็ ๅ˜ๆ›ด +{contributor.total_additions}/-{contributor.total_deletions} ่กŒใ€‚" + ) + else: + return ( + f"{contributor.username} contributed {contributor.total_commits} commits, " + f"submitted {contributor.total_prs} PRs ({contributor.merged_prs} merged), " + f"with +{contributor.total_additions}/-{contributor.total_deletions} lines changed." + ) + + def execute(self, data: dict[str, Any]) -> dict[str, Any] | None: + """Generate summary for contributor updates.""" + if data.get("type") != "contributor_update": + return data + + author = data.get("author", "") + contributor = data.get("contributor") + + # Only generate summary once per author + if author in self._processed_authors: + return None + + self._processed_authors.add(author) + + if contributor: + summary = self._generate_summary(contributor) + data["llm_summary"] = summary + + return data + + +class ReportSink(SinkFunction): + """ + SinkFunction that collects and outputs the final report. + + Supports multiple output formats: console, markdown, JSON. + """ + + def __init__( + self, + output_format: str = "markdown", + output_path: str | Path | None = None, + repos: list[str] | None = None, + days: int | None = None, + period: ReportPeriod | str = ReportPeriod.WEEKLY, + language: str = "zh", + use_llm: bool = True, + **kwargs, + ) -> None: + """ + Initialize report sink. + + Args: + output_format: Output format ("console", "markdown", "json"). + output_path: Path to save the report file. + repos: List of repositories being tracked. + days: Number of days in the report period (overrides period). + period: Report period type (weekly/monthly/quarterly/yearly). + language: Output language ("zh" or "en"). + use_llm: Whether to use LLM for leaderboard generation. + """ + super().__init__(**kwargs) + self.output_format = output_format + self.output_path = Path(output_path) if output_path else None + self.repos = repos or [] + + # Handle period parameter + if isinstance(period, str): + period = ReportPeriod(period.lower()) + self.period = period + + # Days can override period + if days is not None: + self.days = days + else: + self.days = get_period_days(period) + + self.language = language + self.use_llm = use_llm + self.contributors: dict[str, dict[str, Any]] = {} + self.start_time = time.time() + self._llm_client = None + + # Date range based on period + self.start_date, self.end_date = calculate_period_dates(period) + + def _get_llm_client(self): + """Lazy initialization of LLM client.""" + if self._llm_client is None: + try: + from sage.llm import UnifiedInferenceClient + + self._llm_client = UnifiedInferenceClient.create() + except Exception as e: + self.logger.warning(f"Failed to initialize LLM client: {e}") + self._llm_client = None + return self._llm_client + + def _get_period_text(self) -> dict[str, str]: + """Get period-specific text for report generation.""" + period_texts = { + ReportPeriod.WEEKLY: { + "zh_title": "ๆœฌๅ‘จ่ดก็ŒฎๆŽ’่กŒๆฆœ", + "en_title": "Weekly Contribution Leaderboard", + "zh_period": "ๆœฌๅ‘จ", + "en_period": "this week", + "zh_summary_type": "ๅ‘จๆŠฅ", + "en_summary_type": "weekly report", + }, + ReportPeriod.MONTHLY: { + "zh_title": "ๆœฌๆœˆ่ดก็ŒฎๆŽ’่กŒๆฆœ", + "en_title": "Monthly Contribution Leaderboard", + "zh_period": "ๆœฌๆœˆ", + "en_period": "this month", + "zh_summary_type": "ๆœˆๆŠฅ", + "en_summary_type": "monthly report", + }, + ReportPeriod.QUARTERLY: { + "zh_title": "ๆœฌๅญฃๅบฆ่ดก็ŒฎๆŽ’่กŒๆฆœ", + "en_title": "Quarterly Contribution Leaderboard", + "zh_period": "ๆœฌๅญฃๅบฆ", + "en_period": "this quarter", + "zh_summary_type": "ๅญฃๆŠฅ", + "en_summary_type": "quarterly report", + }, + ReportPeriod.YEARLY: { + "zh_title": "ๅนดๅบฆ่ดก็ŒฎๆŽ’่กŒๆฆœ", + "en_title": "Yearly Contribution Leaderboard", + "zh_period": "ๆœฌๅนดๅบฆ", + "en_period": "this year", + "zh_summary_type": "ๅนดๆŠฅ", + "en_summary_type": "yearly report", + }, + } + return period_texts.get(self.period, period_texts[ReportPeriod.WEEKLY]) + + def _generate_leaderboard(self, report: WeeklyReport) -> str: + """Generate a contribution leaderboard with visual chart and optional LLM comments.""" + # Sort contributors by contribution score + sorted_contributors = sorted( + report.contributors, + key=lambda c: c.total_commits + c.merged_prs * 3, + reverse=True, + ) + + # Always generate visual chart first + chart = self._generate_leaderboard_chart(sorted_contributors) + + if not self.use_llm: + return self._generate_simple_leaderboard(report) + + client = self._get_llm_client() + if client is None: + return self._generate_simple_leaderboard(report) + + # Build contributor stats for LLM + contributors_data = [] + for c in report.contributors: + contributors_data.append( + { + "name": c.username, + "commits": c.total_commits, + "prs": c.total_prs, + "merged_prs": c.merged_prs, + "additions": c.total_additions, + "deletions": c.total_deletions, + } + ) + + # Sort by contribution (commits + merged PRs * 3) + contributors_data.sort(key=lambda x: x["commits"] + x["merged_prs"] * 3, reverse=True) + + stats_text = "\n".join( + f"- {i + 1}. {c['name']}: {c['commits']} commits, " + f"{c['prs']} PRs ({c['merged_prs']} merged), " + f"+{c['additions']}/-{c['deletions']} lines" + for i, c in enumerate(contributors_data) + ) + + # Get period-specific text + period_text = self._get_period_text() + + if self.language == "zh": + prompt = f"""ๆ นๆฎไปฅไธ‹่ดก็Œฎ่€…ๆ•ฐๆฎ๏ผŒ็”Ÿๆˆไธ€ไปฝ็ฎ€ๆดๆœ‰่ถฃ็š„{period_text["zh_period"]}่ดก็ŒฎๅบฆๆŽ’่กŒๆฆœ่ฏ„่ฏญใ€‚ +่ฏท็”จemojiๅ’Œ็ฎ€็Ÿญ่ฏ„่ฏญ่ฎฉๆŽ’่กŒๆฆœๆ›ด็”ŸๅŠจใ€‚ + +่ดก็Œฎ่€…ๆ•ฐๆฎ๏ผˆๆŒ‰่ดก็ŒฎๅบฆๆŽ’ๅบ๏ผ‰๏ผš +{stats_text} + +ๆ—ถ้—ด่Œƒๅ›ด๏ผš{report.start_date} ~ {report.end_date} +ๆ€ปๆไบคๆ•ฐ๏ผš{report.total_commits} +ๆ€ปPRๆ•ฐ๏ผš{report.total_prs}๏ผˆๅทฒๅˆๅนถ๏ผš{report.total_merged_prs}๏ผ‰ + +่ฆๆฑ‚๏ผš +1. ็”จ๐Ÿฅ‡๐Ÿฅˆ๐Ÿฅ‰ๆ ‡ๆณจๅ‰ไธ‰ๅ +2. ๆฏไธชไบบ้…ไธ€ๅฅ็ฎ€็Ÿญๆœ‰่ถฃ็š„่ฏ„่ฏญ๏ผˆ10-20ๅญ—๏ผ‰ +3. ๆœ€ๅŽๅŠ ไธ€ๅฅๅ›ข้˜Ÿๆ€ป็ป“ๆˆ–้ผ“ๅŠฑ่ฏญ +4. ไฟๆŒ็ฎ€ๆด๏ผŒๆ€ปๅ…ฑไธ่ถ…่ฟ‡300ๅญ—""" + else: + prompt = f"""Based on the contributor data below, generate brief and fun {period_text["en_period"]} contribution leaderboard comments. +Use emojis and short comments to make it engaging. + +Contributor data (sorted by contribution): +{stats_text} + +Period: {report.start_date} ~ {report.end_date} +Total commits: {report.total_commits} +Total PRs: {report.total_prs} (merged: {report.total_merged_prs}) + +Requirements: +1. Use ๐Ÿฅ‡๐Ÿฅˆ๐Ÿฅ‰ for top 3 +2. Add a short fun comment for each person (10-20 words) +3. End with a team summary or encouragement +4. Keep it concise, under 300 words total""" + + try: + messages = [{"role": "user", "content": prompt}] + response = client.chat(messages) + llm_comments = response.content if hasattr(response, "content") else str(response) + + # Combine chart with LLM comments + title = ( + f"## ๐Ÿ† {period_text['zh_title']}" + if self.language == "zh" + else f"## ๐Ÿ† {period_text['en_title']}" + ) + return f"{title}\n\n{chart}\n\n{llm_comments}" + except Exception as e: + self.logger.warning(f"LLM leaderboard generation failed: {e}") + return self._generate_simple_leaderboard(report) + + def _generate_simple_leaderboard(self, report: WeeklyReport) -> str: + """Generate a simple leaderboard without LLM.""" + # Sort contributors by contribution score + sorted_contributors = sorted( + report.contributors, + key=lambda c: c.total_commits + c.merged_prs * 3, + reverse=True, + ) + + # Generate visual bar chart + chart = self._generate_leaderboard_chart(sorted_contributors) + + # Get period-specific text + period_text = self._get_period_text() + + if self.language == "zh": + lines = [f"## ๐Ÿ† {period_text['zh_title']}", ""] + lines.append(chart) + lines.append("") + medals = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"] + for i, c in enumerate(sorted_contributors): + medal = medals[i] if i < 3 else f"{i + 1}." + lines.append( + f"{medal} **{c.username}** - " + f"{c.total_commits} commits, {c.merged_prs} merged PRs" + ) + lines.append("") + lines.append( + f"*{period_text['zh_period']}ๅ›ข้˜Ÿๅ…ฑๅฎŒๆˆ {report.total_commits} ๆฌกๆไบค๏ผŒ" + f"ๅˆๅนถ {report.total_merged_prs} ไธชPR๏ผŒ็ปง็ปญๅŠ ๆฒนใ€‚*" + ) + else: + lines = [f"## ๐Ÿ† {period_text['en_title']}", ""] + lines.append(chart) + lines.append("") + medals = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"] + for i, c in enumerate(sorted_contributors): + medal = medals[i] if i < 3 else f"{i + 1}." + lines.append( + f"{medal} **{c.username}** - " + f"{c.total_commits} commits, {c.merged_prs} merged PRs" + ) + lines.append("") + lines.append( + f"*Team completed {report.total_commits} commits and " + f"merged {report.total_merged_prs} PRs {period_text['en_period']}. Keep it up.*" + ) + + return "\n".join(lines) + + def _generate_leaderboard_chart(self, sorted_contributors: list) -> str: + """Generate an ASCII/Unicode bar chart for the leaderboard.""" + if not sorted_contributors: + return "" + + # Calculate contribution scores + scores = [] + for c in sorted_contributors: + score = c.total_commits + c.merged_prs * 3 + scores.append((c.username, score, c.total_commits, c.merged_prs)) + + max_score = max(s[1] for s in scores) if scores else 1 + max_name_len = max(len(s[0]) for s in scores) if scores else 10 + max_name_len = min(max_name_len, 20) # Cap at 20 chars + + # Bar characters + bar_full = "โ–ˆ" + bar_width = 30 + + lines = ["```"] + lines.append(f"{'Contributor':<{max_name_len}} โ”‚ {'Score':>6} โ”‚ Progress") + lines.append(f"{'โ”€' * max_name_len}โ”€โ”ผ{'โ”€' * 8}โ”ผ{'โ”€' * (bar_width + 10)}") + + medals = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"] + + for i, (name, score, commits, prs) in enumerate(scores): + # Truncate long names + display_name = name[:max_name_len] if len(name) > max_name_len else name + + # Calculate bar length + bar_len = int((score / max_score) * bar_width) if max_score > 0 else 0 + bar = bar_full * bar_len + + # Medal for top 3 + medal = medals[i] if i < 3 else " " + + # Color-like indicators using different bar styles + lines.append( + f"{display_name:<{max_name_len}} โ”‚ {score:>6} โ”‚ {bar} {medal} ({commits}c+{prs}pr)" + ) + + lines.append("```") + lines.append("") + lines.append("*Score = commits + merged_prs ร— 3*") + + return "\n".join(lines) + + def execute(self, data: dict[str, Any]) -> None: + """Collect data for the report.""" + if data.get("type") == "contributor_update": + author = data.get("author", "Unknown") + self.contributors[author] = { + "contributor": data.get("contributor"), + "llm_summary": data.get("llm_summary", ""), + } + + def close(self) -> None: + """Generate and output the final report.""" + elapsed = time.time() - self.start_time + + # Build report + report = WeeklyReport( + start_date=self.start_date.strftime("%Y-%m-%d"), + end_date=self.end_date.strftime("%Y-%m-%d"), + repos=self.repos, + ) + + for author, data in self.contributors.items(): + contributor = data.get("contributor") + if contributor: + report.contributors.append(contributor) + + report.calculate_overall_stats() + + # Output based on format + if self.output_format == "console": + self._output_console(report, elapsed) + elif self.output_format == "markdown": + self._output_markdown(report, elapsed) + elif self.output_format == "json": + self._output_json(report, elapsed) + + def _output_console(self, report: WeeklyReport, elapsed: float) -> None: + """Output report to console.""" + print("\n" + "=" * 70) + print("Weekly Work Report / ๅ‘จๆŠฅ") + print("=" * 70) + print(f"Period: {report.start_date} ~ {report.end_date}") + print(f"Repositories: {', '.join(report.repos)}") + print("=" * 70) + + print( + f"\n Total: {report.total_commits} commits, " + f"{report.total_prs} PRs ({report.total_merged_prs} merged)" + ) + print("-" * 70) + + for contributor in report.contributors: + print(f"\n {contributor.username}") + print(f" Commits: {contributor.total_commits}") + print(f" PRs: {contributor.total_prs} (merged: {contributor.merged_prs})") + print(f" Lines: +{contributor.total_additions} / -{contributor.total_deletions}") + + # Recent commits + if contributor.commits: + print(" Recent commits:") + for commit in contributor.commits[:3]: + print(f" - {commit.message[:60]}") + + # LLM Summary + llm_summary = self.contributors.get(contributor.username, {}).get("llm_summary", "") + if llm_summary: + print(f" Summary: {llm_summary[:200]}...") + + # Generate and print leaderboard + print("\n" + "-" * 70) + leaderboard = self._generate_leaderboard(report) + print(leaderboard) + + print("\n" + "=" * 70) + print(f" Generated in {elapsed:.2f}s") + print("=" * 70 + "\n") + + def _output_markdown(self, report: WeeklyReport, elapsed: float) -> None: + """Output report as Markdown.""" + lines = [ + "# Weekly Work Report", + "", + f"**Period:** {report.start_date} ~ {report.end_date}", + f"**Repositories:** {', '.join(report.repos)}", + f"**Generated:** {report.generated_at}", + "", + "## Summary", + "", + f"- Total Commits: {report.total_commits}", + f"- Total PRs: {report.total_prs} ({report.total_merged_prs} merged)", + "", + "## Contributors", + "", + ] + + for contributor in report.contributors: + lines.extend( + [ + f"### {contributor.username}", + "", + f"- **Commits:** {contributor.total_commits}", + f"- **PRs:** {contributor.total_prs} (merged: {contributor.merged_prs})", + f"- **Lines Changed:** +{contributor.total_additions} / -{contributor.total_deletions}", + "", + ] + ) + + if contributor.commits: + lines.append("**Recent Commits:**") + for commit in contributor.commits[:5]: + lines.append(f"- [{commit.sha[:7]}]({commit.url}) {commit.message[:60]}") + lines.append("") + + if contributor.pull_requests: + lines.append("**Pull Requests:**") + for pr in contributor.pull_requests[:5]: + status = "" if pr.state == "MERGED" else f"[{pr.state}] " + lines.append(f"- {status}[#{pr.number}]({pr.url}) {pr.title}") + lines.append("") + + llm_summary = self.contributors.get(contributor.username, {}).get("llm_summary", "") + if llm_summary: + lines.extend( + [ + "**AI Summary:**", + "", + f"> {llm_summary}", + "", + ] + ) + + # Add leaderboard section + leaderboard = self._generate_leaderboard(report) + lines.extend( + [ + "", + leaderboard, + "", + "---", + f"*Report generated in {elapsed:.2f}s by SAGE Work Report Generator*", + ] + ) + + content = "\n".join(lines) + + if self.output_path: + self.output_path.parent.mkdir(parents=True, exist_ok=True) + self.output_path.write_text(content, encoding="utf-8") + print(f" Report saved to: {self.output_path}") + else: + print(content) + + def _output_json(self, report: WeeklyReport, elapsed: float) -> None: + """Output report as JSON.""" + data = { + "start_date": report.start_date, + "end_date": report.end_date, + "repos": report.repos, + "generated_at": report.generated_at, + "summary": { + "total_commits": report.total_commits, + "total_prs": report.total_prs, + "total_merged_prs": report.total_merged_prs, + }, + "contributors": [], + } + + for contributor in report.contributors: + contrib_data = { + "username": contributor.username, + "stats": { + "commits": contributor.total_commits, + "prs": contributor.total_prs, + "merged_prs": contributor.merged_prs, + "additions": contributor.total_additions, + "deletions": contributor.total_deletions, + }, + "commits": [ + { + "sha": c.sha, + "message": c.message, + "url": c.url, + "date": c.committed_date, + } + for c in contributor.commits + ], + "pull_requests": [ + { + "number": pr.number, + "title": pr.title, + "state": pr.state, + "url": pr.url, + } + for pr in contributor.pull_requests + ], + "llm_summary": self.contributors.get(contributor.username, {}).get( + "llm_summary", "" + ), + } + data["contributors"].append(contrib_data) + + # Add leaderboard + leaderboard = self._generate_leaderboard(report) + data["leaderboard"] = leaderboard + + content = json.dumps(data, indent=2, ensure_ascii=False) + + if self.output_path: + self.output_path.parent.mkdir(parents=True, exist_ok=True) + self.output_path.write_text(content, encoding="utf-8") + print(f" Report saved to: {self.output_path}") + else: + print(content) + + +class ConsoleSink(SinkFunction): + """Simple sink that logs each item processed.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.count = 0 + + def execute(self, data: dict[str, Any]) -> None: + """Log processing progress.""" + self.count += 1 + data_type = data.get("type", "unknown") + author = data.get("author", "Unknown") + self.logger.info(f"[{self.count}] Processed {data_type} from {author}") diff --git a/apps/src/sage/apps/work_report_generator/pipeline.py b/apps/src/sage/apps/work_report_generator/pipeline.py new file mode 100644 index 0000000..11251c2 --- /dev/null +++ b/apps/src/sage/apps/work_report_generator/pipeline.py @@ -0,0 +1,359 @@ +""" +Work Report Generator Pipeline + +Main pipeline implementation using SAGE operators for weekly/monthly/quarterly/yearly report generation. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from pathlib import Path + +from sage.runtime import LocalEnvironment + +from .models import ReportPeriod +from .operators import ( + ConsoleSink, + ContributorAggregator, + DiaryEntrySource, + GitHubDataSource, + LLMReportGenerator, + ReportSink, + calculate_period_dates, + get_period_days, +) + + +def run_work_report_pipeline( + repos: list[str] | None = None, + days: int | None = None, + period: ReportPeriod | str = ReportPeriod.WEEKLY, + branch: str = "main-dev", + output_format: str = "markdown", + output_path: str | Path | None = None, + diary_path: str | Path | None = None, + language: str = "zh", + github_token: str | None = None, + verbose: bool = False, + use_llm: bool = True, + include_submodules: bool = True, +) -> str | None: + """ + Run the work report generation pipeline using SAGE framework. + + This pipeline: + 1. Fetches commits and PRs from GitHub repositories (main + submodules) + 2. Optionally loads diary entries from local files + 3. Aggregates data by contributor + 4. Generates LLM-powered summaries (optional) + 5. Outputs the final report + + Args: + repos: List of GitHub repositories in "owner/repo" format. + If None, defaults to SAGE main repo + all submodules. + days: Number of days to look back for contributions (overrides period). + period: Report period type (weekly/monthly/quarterly/yearly). + branch: Branch name to fetch commits from. Default "main-dev". + output_format: Output format ("console", "markdown", "json"). + output_path: Path to save the report file. If None, outputs to console. + diary_path: Path to diary directory or file for personal notes. + language: Report language ("zh" for Chinese, "en" for English). + github_token: GitHub personal access token. Uses env var if not provided. + verbose: Enable verbose logging. + use_llm: Whether to use LLM for generating summaries. + include_submodules: If True and repos is None, include all SAGE submodules. + + Returns: + Path to the generated report file, or None if outputting to console. + + Example: + >>> run_work_report_pipeline( + ... period="monthly", + ... branch="main-dev", + ... output_format="markdown", + ... output_path="reports/monthly_report.md" + ... ) + """ + # Handle period parameter + if isinstance(period, str): + period = ReportPeriod(period.lower()) + + # Calculate date range based on period or days + if days is not None: + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + actual_days = days + else: + start_date, end_date = calculate_period_dates(period) + actual_days = get_period_days(period) + + # Get repo list for display + display_repos = ( + repos + if repos + else GitHubDataSource.SAGE_REPOS + if include_submodules + else ["intellistream/SAGE"] + ) + + # Print header + print("=" * 70) + print(f" SAGE Work Report Generator - {period.english_name}") + print("=" * 70) + print(f"Period: {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}") + print(f"Report Type: {period.display_name} ({period.value})") + print(f"Target Branch: {branch}") + print(f"Repositories ({len(display_repos)}):") + for repo in display_repos: + print(f" - {repo}") + print(f"Output Format: {output_format}") + print(f"Language: {'Chinese' if language == 'zh' else 'English'}") + print(f"LLM Summary: {'Enabled' if use_llm else 'Disabled'}") + print("=" * 70) + print() + + # Reset aggregator state (important for multiple pipeline runs) + ContributorAggregator.reset() + + # Create SAGE environment + env = LocalEnvironment("work_report_generator") + + # Determine output path + if output_path is None and output_format != "console": + output_dir = Path(".sage/reports") + output_dir.mkdir(parents=True, exist_ok=True) + ext = "json" if output_format == "json" else "md" + # Use period name in filename + output_path = output_dir / f"{period.value}_report_{end_date.strftime('%Y%m%d')}.{ext}" + + # Build pipeline: + # 1. GitHubDataSource: Fetch commits and PRs from main-dev branch + # 2. ContributorAggregator: Group by contributor + # 3. LLMReportGenerator: Generate AI summaries (optional) + # 4. ReportSink: Output final report + + pipeline = env.from_batch( + GitHubDataSource, + repos=repos, + days=actual_days, + period=period, + branch=branch, + github_token=github_token, + include_submodules=include_submodules, + ).map(ContributorAggregator) + + # Optionally add LLM summary generation + if use_llm: + pipeline = pipeline.map(LLMReportGenerator, language=language) + + # Add report sink + pipeline = pipeline.sink( + ReportSink, + output_format=output_format, + output_path=output_path, + repos=repos, + days=actual_days, + period=period, + language=language, + use_llm=use_llm, + ) + + # Optionally add verbose logging + if verbose: + pipeline.sink(ConsoleSink) + + # Handle diary entries if provided + if diary_path: + diary_pipeline = env.from_batch( + DiaryEntrySource, + diary_path=diary_path, + days=actual_days, + period=period, + ).map(ContributorAggregator) + + if use_llm: + diary_pipeline = diary_pipeline.map(LLMReportGenerator, language=language) + + diary_pipeline.sink( + ReportSink, + output_format=output_format, + output_path=output_path, + repos=repos, + days=actual_days, + period=period, + ) + + # Execute pipeline + print(" Starting pipeline...") + print() + env.submit(autostop=True) + + return str(output_path) if output_path else None + + +def main(): + """Main entry point for command-line usage.""" + import argparse + + parser = argparse.ArgumentParser( + description="SAGE Work Report Generator - Generate periodic work reports", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Generate weekly report for all SAGE repositories (default) + python -m sage.apps.work_report_generator.pipeline + + # Generate monthly report + python -m sage.apps.work_report_generator.pipeline --period monthly + + # Generate quarterly report + python -m sage.apps.work_report_generator.pipeline --period quarterly + + # Generate yearly report + python -m sage.apps.work_report_generator.pipeline --period yearly + + # Specify custom repositories + python -m sage.apps.work_report_generator.pipeline \\ + --repos intellistream/SAGE,intellistream/sageLLM + + # Use different branch + python -m sage.apps.work_report_generator.pipeline --branch main + + # Main repo only (without submodules) + python -m sage.apps.work_report_generator.pipeline --no-submodules + + # Custom time range (overrides period) + python -m sage.apps.work_report_generator.pipeline \\ + --days 14 \\ + --output reports/biweekly.md \\ + --format markdown + + # With diary entries + python -m sage.apps.work_report_generator.pipeline \\ + --diary-path ./diaries \\ + --language zh + + # Skip LLM for faster generation + python -m sage.apps.work_report_generator.pipeline --no-llm + """, + ) + + parser.add_argument( + "--period", + "-p", + type=str, + choices=["weekly", "monthly", "quarterly", "yearly"], + default="weekly", + help="Report period type (default: weekly)", + ) + + parser.add_argument( + "--repos", + "-r", + type=str, + help="Comma-separated list of repositories (owner/repo format). " + "If not specified, fetches from all SAGE repos including submodules.", + ) + + parser.add_argument( + "--branch", + "-b", + type=str, + default="main-dev", + help="Branch name to fetch commits from (default: main-dev)", + ) + + parser.add_argument( + "--days", + "-d", + type=int, + default=None, + help="Number of days to look back (overrides --period)", + ) + + parser.add_argument( + "--format", + "-f", + type=str, + choices=["console", "markdown", "json"], + default="markdown", + help="Output format (default: markdown)", + ) + + parser.add_argument( + "--output", + "-o", + type=str, + help="Output file path", + ) + + parser.add_argument( + "--diary-path", + type=str, + help="Path to diary directory or file", + ) + + parser.add_argument( + "--language", + "-l", + type=str, + choices=["zh", "en"], + default="zh", + help="Report language (default: zh)", + ) + + parser.add_argument( + "--token", + "-t", + type=str, + help="GitHub personal access token", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output", + ) + + parser.add_argument( + "--no-llm", + action="store_true", + help="Disable LLM summary generation", + ) + + parser.add_argument( + "--no-submodules", + action="store_true", + help="Only fetch from main SAGE repo, skip submodules", + ) + + args = parser.parse_args() + + # Parse repositories + repos = None + if args.repos: + repos = [r.strip() for r in args.repos.split(",")] + + # Run pipeline + result = run_work_report_pipeline( + repos=repos, + days=args.days, + period=args.period, + branch=args.branch, + output_format=args.format, + output_path=args.output, + diary_path=args.diary_path, + language=args.language, + github_token=args.token, + verbose=args.verbose, + use_llm=not args.no_llm, + include_submodules=not args.no_submodules, + ) + + if result: + print(f"\n Report generated: {result}") + + +if __name__ == "__main__": + main() diff --git a/apps/tests/__init__.py b/apps/tests/__init__.py new file mode 100644 index 0000000..ca8b5e0 --- /dev/null +++ b/apps/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for sage-apps package.""" diff --git a/apps/tests/article_monitoring/__init__.py b/apps/tests/article_monitoring/__init__.py new file mode 100644 index 0000000..8ce0db1 --- /dev/null +++ b/apps/tests/article_monitoring/__init__.py @@ -0,0 +1 @@ +"""Tests for Article Monitoring application""" diff --git a/apps/tests/article_monitoring/test_operators.py b/apps/tests/article_monitoring/test_operators.py new file mode 100644 index 0000000..27533ad --- /dev/null +++ b/apps/tests/article_monitoring/test_operators.py @@ -0,0 +1,322 @@ +"""Unit tests for Article Monitoring operators + +Tests for sage.apps.article_monitoring.operators module +""" + +import pytest + +from sage.apps.article_monitoring.operators import ( + Article, + ArticleLogSink, + ArticleRankingSink, + ArticleScorer, + ArxivSource, + KeywordFilter, + SemanticFilter, +) + + +class TestArticle: + """Test Article dataclass""" + + def test_article_creation(self): + """Test creating an article""" + article = Article( + id="2401.12345", + title="Test Article", + authors=["Author One", "Author Two"], + abstract="This is a test abstract about machine learning.", + published="2024-01-15", + categories=["cs.AI", "cs.LG"], + url="https://arxiv.org/abs/2401.12345", + ) + + assert article.id == "2401.12345" + assert article.title == "Test Article" + assert len(article.authors) == 2 + assert article.keyword_score == 0.0 + assert article.semantic_score == 0.0 + assert article.total_score == 0.0 + + +class TestArxivSource: + """Test ArxivSource operator""" + + def test_source_creation(self): + """Test creating arXiv source""" + source = ArxivSource(category="cs.AI", max_results=10) + + assert source.category == "cs.AI" + assert source.max_results == 10 + assert source.index == 0 + assert source.fetched is False + + def test_source_generates_mock_articles(self): + """Test source generates mock articles when API unavailable""" + source = ArxivSource(category="cs.AI", max_results=3) + + # Force using mock data + mock_articles = source._get_mock_articles() + + assert len(mock_articles) == 5 # Default mock data has 5 articles + assert all("id" in article for article in mock_articles) + assert all("title" in article for article in mock_articles) + + def test_source_execute_returns_articles_one_by_one(self): + """Test source executes and returns one article at a time""" + source = ArxivSource(category="cs.AI", max_results=5) + + # Manually set mock articles to test iteration + source.articles = source._get_mock_articles() + source.fetched = True + + # Get first article + article1 = source.execute() + assert article1 is not None + assert "id" in article1 + + # Get second article + article2 = source.execute() + assert article2 is not None + assert article2["id"] != article1["id"] + + def test_source_parse_xml_with_missing_elements(self): + """Test XML parsing handles missing elements gracefully""" + source = ArxivSource(category="cs.AI", max_results=10) + + # Create malformed XML with missing required elements + malformed_xml = """ + + + http://arxiv.org/abs/2401.00001 + Valid Article + This has all required fields + 2024-01-01T00:00:00Z + Test Author + + + + http://arxiv.org/abs/2401.00002 + + Incomplete Article + + + """ + + try: + articles = source._parse_arxiv_response(malformed_xml) + # Should only parse the valid entry, skip malformed ones + assert len(articles) >= 1 + if len(articles) > 0: + assert articles[0]["id"] == "2401.00001" + except Exception: + # XML parsing might fail entirely, which is also acceptable + pass + + def test_source_parse_xml_with_missing_author_name(self): + """Test XML parsing handles authors without names""" + source = ArxivSource(category="cs.AI", max_results=10) + + xml_with_authors = """ + + + http://arxiv.org/abs/2401.00001 + Test Article + Test summary + 2024-01-01T00:00:00Z + Valid Author + + + + """ + + try: + articles = source._parse_arxiv_response(xml_with_authors) + # Should have at least one article + assert len(articles) >= 1 + if len(articles) > 0 and "authors" in articles[0]: + # Should have authors + assert len(articles[0]["authors"]) >= 1 + except Exception: + # XML parsing might fail, acceptable + pass + + +class TestKeywordFilter: + """Test KeywordFilter operator""" + + def test_filter_creation(self): + """Test creating keyword filter""" + keywords = ["machine learning", "deep learning", "AI"] + filter_op = KeywordFilter(keywords=keywords, min_score=2.0) + + assert len(filter_op.keywords) == 3 + assert filter_op.min_score == 2.0 + + def test_filter_article_high_match(self): + """Test filtering article with high keyword match""" + filter_op = KeywordFilter(keywords=["stream", "processing", "data"], min_score=2.0) + + article_data = { + "id": "2401.00001", + "title": "Real-time Stream Processing", + "abstract": "We present a novel approach for processing data streams...", + "authors": ["John Doe"], + } + + result = filter_op.execute(article_data) + assert result is not None + assert result["keyword_score"] >= 2.0 + + def test_filter_article_low_match(self): + """Test filtering article with low keyword match""" + filter_op = KeywordFilter(keywords=["quantum", "computing"], min_score=2.0) + + article_data = { + "id": "2401.00002", + "title": "Machine Learning Basics", + "abstract": "Introduction to machine learning concepts...", + "authors": ["Jane Smith"], + } + + result = filter_op.execute(article_data) + assert result is None + + +class TestSemanticFilter: + """Test SemanticFilter operator""" + + def test_filter_creation(self): + """Test creating semantic filter""" + topics = ["distributed systems", "stream processing"] + filter_op = SemanticFilter(interest_topics=topics, min_similarity=0.3) + + assert len(filter_op.interest_topics) == 2 + assert filter_op.min_similarity == 0.3 + + def test_filter_semantic_match(self): + """Test semantic filtering with matching content""" + filter_op = SemanticFilter( + interest_topics=["machine learning neural networks"], min_similarity=0.05 + ) + + article_data = { + "id": "2401.00001", + "title": "Deep Neural Networks for Classification", + "abstract": "We explore deep learning and neural network architectures...", + "keyword_score": 2.0, + } + + result = filter_op.execute(article_data) + assert result is not None + assert "semantic_score" in result + assert result["semantic_score"] > 0 + + +class TestArticleScorer: + """Test ArticleScorer operator""" + + def test_scorer_calculates_total_score(self): + """Test scorer calculates total score""" + scorer = ArticleScorer() + + article_data = { + "id": "2401.00001", + "title": "Test Article", + "keyword_score": 3.0, + "semantic_score": 2.5, + } + + result = scorer.execute(article_data) + assert result["total_score"] == 5.5 + + +class TestArticleRankingSink: + """Test ArticleRankingSink operator""" + + def test_sink_creation(self): + """Test creating ranking sink""" + sink = ArticleRankingSink() + + assert len(sink.articles) == 0 + + def test_sink_collects_articles(self): + """Test sink collects articles""" + sink = ArticleRankingSink() + + article1 = { + "id": "2401.00001", + "title": "Article 1", + "total_score": 5.0, + "authors": ["Author 1"], + "url": "http://test.com/1", + } + + article2 = { + "id": "2401.00002", + "title": "Article 2", + "total_score": 3.0, + "authors": ["Author 2"], + "url": "http://test.com/2", + } + + sink.execute(article1) + sink.execute(article2) + + assert len(sink.articles) == 2 + + +class TestArticleLogSink: + """Test ArticleLogSink operator""" + + def test_sink_logs_articles(self): + """Test sink logs articles""" + sink = ArticleLogSink() + + article = { + "id": "2401.00001", + "title": "Test Article for Logging", + "total_score": 4.5, + } + + sink.execute(article) + assert sink.count == 1 + + +@pytest.mark.integration +class TestArticleMonitoringPipeline: + """Integration tests for article monitoring pipeline""" + + def test_end_to_end_flow(self): + """Test complete article monitoring flow""" + # Create operators + source = ArxivSource(category="cs.AI", max_results=3) + keyword_filter = KeywordFilter(keywords=["learning", "neural"], min_score=1.0) + semantic_filter = SemanticFilter(interest_topics=["machine learning"], min_similarity=0.03) + scorer = ArticleScorer() + ranking_sink = ArticleRankingSink() + + # Execute pipeline manually + processed_count = 0 + while True: + article = source.execute() + if article is None: + break + + # Apply filters + filtered = keyword_filter.execute(article) + if filtered is None: + continue + + filtered = semantic_filter.execute(filtered) + if filtered is None: + continue + + # Score and collect + scored = scorer.execute(filtered) + ranking_sink.execute(scored) + processed_count += 1 + + # Should have processed at least some articles + assert processed_count >= 0 + assert len(ranking_sink.articles) >= 0 diff --git a/apps/tests/auto_scaling_chat/__init__.py b/apps/tests/auto_scaling_chat/__init__.py new file mode 100644 index 0000000..0b9528b --- /dev/null +++ b/apps/tests/auto_scaling_chat/__init__.py @@ -0,0 +1 @@ +"""Tests for Auto-Scaling Chat application""" diff --git a/apps/tests/auto_scaling_chat/test_operators.py b/apps/tests/auto_scaling_chat/test_operators.py new file mode 100644 index 0000000..43fdeda --- /dev/null +++ b/apps/tests/auto_scaling_chat/test_operators.py @@ -0,0 +1,294 @@ +"""Unit tests for Auto-Scaling Chat operators + +Tests for sage.apps.auto_scaling_chat.operators module +""" + +import pytest + +from sage.apps.auto_scaling_chat.operators import ( + AutoScaler, + LoadBalancer, + MetricsCollector, + RequestProcessor, + ScalingEventsSink, + UserRequest, + UserTrafficSource, +) + + +class TestUserRequest: + """Test UserRequest dataclass""" + + def test_request_creation(self): + """Test creating a user request""" + request = UserRequest( + request_id=1, + user_id=101, + timestamp="2024-01-15T10:00:00", + message="Hello, how are you?", + ) + + assert request.request_id == 1 + assert request.user_id == 101 + assert request.message == "Hello, how are you?" + assert request.processing_time == 0.0 + assert request.server_id is None + + +class TestUserTrafficSource: + """Test UserTrafficSource operator""" + + def test_source_creation(self): + """Test creating user traffic source""" + source = UserTrafficSource(duration=10, base_rate=5, peak_rate=20) + + assert source.duration == 10 + assert source.base_rate == 5 + assert source.peak_rate == 20 + assert source.request_count == 0 + + def test_source_generates_requests(self): + """Test source generates user requests""" + source = UserTrafficSource(duration=1, base_rate=50, peak_rate=100) + + # Execute a few times + requests = [] + for _ in range(10): + result = source.execute() + if result is not None: + requests.append(result) + + # Should generate at least some requests with high rate + assert len(requests) >= 0 + + def test_source_respects_duration(self): + """Test source stops after duration""" + source = UserTrafficSource(duration=0.1, base_rate=10, peak_rate=20) + + import time + + time.sleep(0.15) # Wait past duration + + result = source.execute() + assert result is None # Should return None after duration + + +class TestLoadBalancer: + """Test LoadBalancer operator""" + + def test_balancer_creation(self): + """Test creating load balancer""" + balancer = LoadBalancer(initial_servers=3) + + assert balancer.num_servers == 3 + assert balancer.current_server == 0 + + def test_balancer_round_robin(self): + """Test round-robin load balancing""" + balancer = LoadBalancer(initial_servers=3) + + request1 = {"request_id": 1, "message": "test1"} + request2 = {"request_id": 2, "message": "test2"} + request3 = {"request_id": 3, "message": "test3"} + request4 = {"request_id": 4, "message": "test4"} + + result1 = balancer.execute(request1) + result2 = balancer.execute(request2) + result3 = balancer.execute(request3) + result4 = balancer.execute(request4) + + # Should distribute across servers 0, 1, 2, 0 + assert result1["server_id"] == 0 + assert result2["server_id"] == 1 + assert result3["server_id"] == 2 + assert result4["server_id"] == 0 + + def test_balancer_scale_servers(self): + """Test scaling number of servers""" + balancer = LoadBalancer(initial_servers=2) + + balancer.scale_servers(5) + assert balancer.num_servers == 5 + + +class TestAutoScaler: + """Test AutoScaler operator""" + + def test_scaler_creation(self): + """Test creating auto scaler""" + scaler = AutoScaler( + scale_up_threshold=30, scale_down_threshold=10, min_servers=2, max_servers=10 + ) + + assert scaler.scale_up_threshold == 30 + assert scaler.scale_down_threshold == 10 + assert scaler.min_servers == 2 + assert scaler.max_servers == 10 + assert scaler.current_servers == 2 + + def test_scaler_scale_up_decision(self): + """Test scale up decision""" + scaler = AutoScaler(scale_up_threshold=20, min_servers=2, max_servers=10) + + # High load should trigger scale up + high_load_data = {"current_load": 50, "request_id": 1} + + result = scaler.execute(high_load_data) + + # Should scale up + assert result["scaling_action"] in ["scale_up", "cooldown"] + if result["scaling_action"] == "scale_up": + assert result["total_servers"] == 3 + + def test_scaler_respects_cooldown(self): + """Test cooldown period""" + scaler = AutoScaler(scale_up_threshold=20, cooldown=2.0) + + # First scale up + data1 = {"current_load": 50} + scaler.execute(data1) + + # Immediate second attempt should be in cooldown + data2 = {"current_load": 50} + result2 = scaler.execute(data2) + + assert result2["scaling_action"] == "cooldown" + + +class TestRequestProcessor: + """Test RequestProcessor operator""" + + def test_processor_creation(self): + """Test creating request processor""" + processor = RequestProcessor(processing_time=0.1) + + assert processor.processing_time == 0.1 + + def test_processor_processes_request(self): + """Test processing a request""" + processor = RequestProcessor(processing_time=0.01) + + request_data = { + "request_id": 1, + "user_id": 123, + "message": "Hello", + "server_id": 0, + } + + result = processor.execute(request_data) + + assert result is not None + assert "processing_time" in result + assert "processed_at" in result + assert result["request_id"] == 1 + + +class TestMetricsCollector: + """Test MetricsCollector operator""" + + def test_collector_creation(self): + """Test creating metrics collector""" + collector = MetricsCollector() + + assert collector.total_requests == 0 + assert collector.scaling_events == 0 + assert collector.peak_servers == 0 + assert collector.peak_load == 0 + + def test_collector_collects_metrics(self): + """Test collecting metrics""" + collector = MetricsCollector() + + data1 = {"current_load": 10, "total_servers": 2, "scaling_action": "none"} + data2 = {"current_load": 30, "total_servers": 3, "scaling_action": "scale_up"} + data3 = {"current_load": 15, "total_servers": 3, "scaling_action": "none"} + + collector.execute(data1) + collector.execute(data2) + collector.execute(data3) + + assert collector.total_requests == 3 + assert collector.peak_load == 30 + assert collector.peak_servers == 3 + assert collector.scaling_events == 1 + + +class TestScalingEventsSink: + """Test ScalingEventsSink operator""" + + def test_sink_creation(self): + """Test creating scaling events sink""" + sink = ScalingEventsSink() + + assert len(sink.events) == 0 + + def test_sink_logs_scaling_events(self): + """Test logging scaling events""" + sink = ScalingEventsSink() + + # Non-scaling event should not be logged + data1 = {"scaling_action": "none", "total_servers": 2} + sink.execute(data1) + + assert len(sink.events) == 0 + + # Scaling events should be logged + data2 = { + "scaling_action": "scale_up", + "total_servers": 3, + "current_load": 50, + "timestamp": "2024-01-15T10:00:00", + } + sink.execute(data2) + + assert len(sink.events) == 1 + assert sink.events[0]["action"] == "scale_up" + assert sink.events[0]["servers"] == 3 + + +@pytest.mark.integration +class TestAutoScalingPipeline: + """Integration tests for auto-scaling chat system""" + + def test_end_to_end_flow(self): + """Test complete auto-scaling flow""" + # Create operators + source = UserTrafficSource(duration=2, base_rate=10, peak_rate=30) + balancer = LoadBalancer(initial_servers=2) + scaler = AutoScaler(scale_up_threshold=20, scale_down_threshold=5, min_servers=2) + processor = RequestProcessor(processing_time=0.01) + metrics = MetricsCollector() + events_sink = ScalingEventsSink() + + # Execute pipeline manually + processed = 0 + max_iterations = 100 # Safety limit + + for _ in range(max_iterations): + # Generate traffic + request_data = source.execute() + if request_data is None: + break + + # Load balance + balanced = balancer.execute(request_data) + + # Make scaling decision + scaled = scaler.execute(balanced) + + # Update balancer if scaled + if scaled["scaling_action"] in ["scale_up", "scale_down"]: + balancer.scale_servers(scaled["total_servers"]) + + # Process request + processed_request = processor.execute(scaled) + + # Collect metrics + metrics.execute(processed_request) + events_sink.execute(processed_request) + + processed += 1 + + # Verify execution + assert processed >= 0 + assert metrics.total_requests == processed diff --git a/apps/tests/literature_report_assistant/test_pipeline.py b/apps/tests/literature_report_assistant/test_pipeline.py new file mode 100644 index 0000000..22aa47a --- /dev/null +++ b/apps/tests/literature_report_assistant/test_pipeline.py @@ -0,0 +1,111 @@ +from sage.apps.literature_report_assistant.pipeline import ( + LiteraturePaper, + ResearchProfile, + generate_literature_report, + generate_personalized_recommendation_report, + preprocess_paper_stream, + rank_papers, + recommend_papers, +) + + +def test_rank_papers_prefers_topic_overlap() -> None: + papers = [ + LiteraturePaper( + title="Agentic RAG with tool use", + summary="A retrieval augmented generation framework for agent systems.", + url="https://arxiv.org/abs/0001.00001", + published="2026-01-01", + ), + LiteraturePaper( + title="Image denoising baseline", + summary="Classical denoising for grayscale images.", + url="https://arxiv.org/abs/0001.00002", + published="2026-01-02", + ), + ] + + ranked = rank_papers(papers, topic="agentic rag retrieval", top_k=1) + assert len(ranked) == 1 + assert "RAG" in ranked[0].title + + +def test_generate_report_contains_topic_and_papers() -> None: + papers = [ + LiteraturePaper( + title="RAG Survey", + summary="Comprehensive survey on retrieval-augmented generation.", + url="https://arxiv.org/abs/1234.5678", + published="2026-02-01", + ) + ] + report = generate_literature_report("RAG", papers) + assert "ๆ–‡็Œฎ้˜…่ฏปๆŠฅๅ‘Š๏ผšRAG" in report + assert "RAG Survey" in report + assert "ๅปบ่ฎฎ้˜…่ฏป้กบๅบ" in report + + +def test_preprocess_stream_deduplicates_and_normalizes() -> None: + papers = [ + LiteraturePaper( + title=" Agentic RAG ", + summary=" First summary. ", + url="https://arxiv.org/abs/1111.1111 ", + published="2026-01-01", + ), + LiteraturePaper( + title="Agentic RAG", + summary="First summary.", + url="https://arxiv.org/abs/1111.1111", + published="2026-01-01", + ), + ] + + cleaned = preprocess_paper_stream(papers) + assert len(cleaned) == 1 + assert cleaned[0].title == "Agentic RAG" + assert cleaned[0].summary == "First summary." + + +def test_recommend_papers_returns_reasoned_results() -> None: + papers = [ + LiteraturePaper( + title="Streaming RAG for Scientific Recommendation", + summary="Real-time retrieval and recommendation for arXiv streams.", + url="https://arxiv.org/abs/2222.2222", + published="2026-02-10", + ), + LiteraturePaper( + title="Classic computer vision benchmark", + summary="No relevance to LLM literature recommendation.", + url="https://arxiv.org/abs/3333.3333", + published="2026-02-11", + ), + ] + profile = ResearchProfile(interests=["scientific recommendation"], keywords=["rag", "stream"]) + + recommendations = recommend_papers(papers, profile, top_k=3) + assert len(recommendations) == 1 + assert "ๅŒน้…ๅ…ณ้”ฎ่ฏ" in recommendations[0].reason + assert "RAG" in recommendations[0].paper.title + + +def test_personalized_report_contains_reason() -> None: + profile = ResearchProfile(interests=["agentic"], keywords=["rag"]) + recommendations = recommend_papers( + [ + LiteraturePaper( + title="Agentic RAG Survey", + summary="Survey on agentic retrieval systems.", + url="https://arxiv.org/abs/4444.4444", + published="2026-03-01", + ) + ], + profile, + top_k=1, + ) + + report = generate_personalized_recommendation_report("agentic rag", recommendations) + assert "ไธชๆ€งๅŒ–ๆ–‡็ŒฎๆŽจ่ๆŠฅๅ‘Š" in report + assert "ๆŽจ่็†็”ฑ" in report + assert "Agentic RAG Survey" in report diff --git a/apps/tests/medical_diagnosis/__init__.py b/apps/tests/medical_diagnosis/__init__.py new file mode 100644 index 0000000..abfd540 --- /dev/null +++ b/apps/tests/medical_diagnosis/__init__.py @@ -0,0 +1 @@ +"""Medical diagnosis application tests.""" diff --git a/apps/tests/medical_diagnosis/conftest.py b/apps/tests/medical_diagnosis/conftest.py new file mode 100644 index 0000000..421bbe6 --- /dev/null +++ b/apps/tests/medical_diagnosis/conftest.py @@ -0,0 +1,72 @@ +"""pytest configuration for medical diagnosis tests.""" + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def prepare_minimal_test_data(): + """ + Prepare minimal test data for medical diagnosis tests. + + This fixture automatically runs before any tests in this directory. + It creates a minimal test dataset if the real data is not available. + """ + # Get the medical diagnosis app directory + test_dir = Path(__file__).parent + app_dir = test_dir.parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" + data_dir = app_dir / "data" / "processed" + test_index_path = data_dir / "test_index.json" + + # If real data exists, skip creating mock data + if test_index_path.exists(): + print(f"โœ“ Using existing test data at {test_index_path}") + return + + # Create minimal mock test data + print(f"โœ“ Creating minimal test data at {test_index_path}") + data_dir.mkdir(parents=True, exist_ok=True) + + # Create minimal test cases (no actual images needed for structure tests) + mock_test_cases = [ + { + "case_id": "mock_001", + "patient_id": "P001", + "age": 45, + "gender": "็”ท", + "disease": "ๆญฃๅธธ", + "severity": "ๆ— ", + "image_path": "mock_images/case_001.npy", + }, + { + "case_id": "mock_002", + "patient_id": "P002", + "age": 52, + "gender": "ๅฅณ", + "disease": "ๆคŽ้—ด็›˜็ชๅ‡บ", + "severity": "ไธญๅบฆ", + "image_path": "mock_images/case_002.npy", + }, + ] + + # Save test index + with open(test_index_path, "w", encoding="utf-8") as f: + json.dump(mock_test_cases, f, ensure_ascii=False, indent=2) + + # Create mock image directory + mock_image_dir = data_dir / "mock_images" + mock_image_dir.mkdir(parents=True, exist_ok=True) + + # Create placeholder image files (empty numpy arrays) + import numpy as np + + for case in mock_test_cases: + image_path = data_dir / case["image_path"] + if not image_path.exists(): + # Create a minimal numpy array as placeholder + mock_image = np.zeros((64, 64), dtype=np.float32) + np.save(image_path, mock_image) + + print(f"โœ“ Created {len(mock_test_cases)} mock test cases") diff --git a/apps/tests/medical_diagnosis/demo_knowledge_loading.py b/apps/tests/medical_diagnosis/demo_knowledge_loading.py new file mode 100644 index 0000000..701cd2b --- /dev/null +++ b/apps/tests/medical_diagnosis/demo_knowledge_loading.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +ๆผ”็คบๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“็š„็Ÿฅ่ฏ†ๅŠ ่ฝฝๅŠŸ่ƒฝ +Demonstrate the knowledge loading functionality of the MedicalKnowledgeBase +""" + +import json +import sys +from pathlib import Path + +# Add the source directory to the path +src_dir = Path(__file__).parent.parent.parent / "src" +sys.path.insert(0, str(src_dir)) + +from sage.apps.medical_diagnosis.tools.knowledge_base import MedicalKnowledgeBase + + +def create_sample_data(): + """ๅˆ›ๅปบ็คบไพ‹ๆ•ฐๆฎๆ–‡ไปถ็”จไบŽๆผ”็คบ""" + print("=" * 80) + print("ๅˆ›ๅปบ็คบไพ‹ๆ•ฐๆฎๆ–‡ไปถ...") + print("=" * 80) + + # ๅˆ›ๅปบไธดๆ—ถๆ•ฐๆฎ็›ฎๅฝ• + data_dir = Path(__file__).parent / "test_data" + data_dir.mkdir(exist_ok=True) + + # ๅˆ›ๅปบๆŠฅๅ‘Š็›ฎๅฝ• + reports_dir = data_dir / "reports" + reports_dir.mkdir(exist_ok=True) + + # ๅˆ›ๅปบ็คบไพ‹ๆŠฅๅ‘Š + sample_reports = [ + { + "filename": "case_0001_report.txt", + "content": """ๆ‚ฃ่€…ไฟกๆฏ: + ๅนด้พ„: 48ๅฒ + ๆ€งๅˆซ: ็”ท + ไธป่ฏ‰: ่…ฐ็—›ไผดๅทฆไธ‹่‚ขๆ”พๅฐ„็—› + +ๅฝฑๅƒๆ่ฟฐ: + ่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: L4/L5ๆคŽ้—ด็›˜ๅ‘ๅŽ็ชๅ‡บ๏ผŒๅŽ‹่ฟซ็กฌ่†œๅ›Šใ€‚็›ธๅบ”่Š‚ๆฎตๆคŽ็ฎกๅ˜็ช„๏ผŒ็ฅž็ปๆ นๅฏ่ƒฝๅ—ๅŽ‹ใ€‚ + +ไธป่ฆๅ‘็Žฐ: + - ็—…ๅ˜่Š‚ๆฎต: L4/L5 + - ็—…ๅ˜็ฑปๅž‹: ๆคŽ้—ด็›˜็ชๅ‡บ + - ไธฅ้‡็จ‹ๅบฆ: ไธญๅบฆ + +่ฏŠๆ–ญ็ป“่ฎบ: + L4/L5ๆคŽ้—ด็›˜็ชๅ‡บ็—‡๏ผŒ็จ‹ๅบฆไธญๅบฆใ€‚ + +ๆฒป็–—ๅปบ่ฎฎ: + ๅปบ่ฎฎๅงๅบŠไผ‘ๆฏ2-3ๅ‘จ๏ผŒ็‰ตๅผ•ๆฒป็–—ใ€‚ๅฃๆœ้ž็”พไฝ“ๆŠ—็‚Ž่ฏๅŠ็ฅž็ป่ฅๅ…ป่ฏ็‰ฉใ€‚ไฟๅฎˆๆฒป็–—ๆ— ๆ•ˆๆ—ถ่€ƒ่™‘ๆ‰‹ๆœฏๆฒป็–—ใ€‚ + +ๆณจ: ๆœฌๆŠฅๅ‘Šไป…ไพ›ๅ‚่€ƒ๏ผŒ่ฏท็ป“ๅˆไธดๅบŠ็—‡็Šถๅ’Œๅ…ถไป–ๆฃ€ๆŸฅ็ป“ๆžœ็ปผๅˆๅˆคๆ–ญใ€‚ +""", + }, + { + "filename": "case_0002_report.txt", + "content": """ๆ‚ฃ่€…ไฟกๆฏ: + ๅนด้พ„: 60ๅฒ + ๆ€งๅˆซ: ็”ท + ไธป่ฏ‰: ๆ…ขๆ€ง่…ฐ็—› + +ๅฝฑๅƒๆ่ฟฐ: + ่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: L3/L4, L4/L5, L5/S1ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜ใ€‚ๆคŽ็ฎกๅฐš้€š็•…๏ผŒๆœช่งๆ˜Žๆ˜พ็ฅž็ปๆ นๅ—ๅŽ‹ใ€‚ + +ไธป่ฆๅ‘็Žฐ: + - ็—…ๅ˜่Š‚ๆฎต: ๅคš่Š‚ๆฎต + - ็—…ๅ˜็ฑปๅž‹: ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜ + - ไธฅ้‡็จ‹ๅบฆ: ไธญๅบฆ + +่ฏŠๆ–ญ็ป“่ฎบ: + ่…ฐๆคŽ้€€่กŒๆ€งๅ˜๏ผŒL3/L4ใ€L4/L5ๆคŽ้—ด็›˜็ชๅ‡บ๏ผŒ็จ‹ๅบฆไธญๅบฆใ€‚ + +ๆฒป็–—ๅปบ่ฎฎ: + ้€‚ๅฝ“ไผ‘ๆฏ๏ผŒ้ฟๅ…ไน…ๅไน…็ซ™ใ€‚ๅฏ่ฟ›่กŒ่…ฐ่ƒŒ่‚Œ้”ป็‚ผ๏ผŒๅฆ‚ๆธธๆณณใ€ๆ™ฎๆ‹‰ๆ็ญ‰ใ€‚ๅฟ…่ฆๆ—ถ็‰ฉ็†ๆฒป็–—ใ€‚ + +ๆณจ: ๆœฌๆŠฅๅ‘Šไป…ไพ›ๅ‚่€ƒ๏ผŒ่ฏท็ป“ๅˆไธดๅบŠ็—‡็Šถๅ’Œๅ…ถไป–ๆฃ€ๆŸฅ็ป“ๆžœ็ปผๅˆๅˆคๆ–ญใ€‚ +""", + }, + ] + + for report in sample_reports: + report_path = reports_dir / report["filename"] + with open(report_path, "w", encoding="utf-8") as f: + f.write(report["content"]) + print(f" โœ“ ๅˆ›ๅปบๆŠฅๅ‘Š: {report['filename']}") + + # ๅˆ›ๅปบ็ปŸ่ฎกๆ–‡ไปถ + stats = { + "total_samples": 100, + "train_samples": 80, + "test_samples": 20, + "disease_distribution": { + "ๆญฃๅธธ": 10, + "่ฝปๅบฆ้€€่กŒๆ€งๅ˜": 20, + "ๆคŽ้—ด็›˜็ชๅ‡บ": 25, + "ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜": 15, + "ๆคŽ็ฎก็‹ญ็ช„": 12, + "ๆคŽ้—ด็›˜่„ฑๅ‡บ": 8, + "้ชจ่ดจๅขž็”Ÿ": 10, + }, + } + + stats_path = data_dir / "stats.json" + with open(stats_path, "w", encoding="utf-8") as f: + json.dump(stats, f, ensure_ascii=False, indent=2) + print(" โœ“ ๅˆ›ๅปบ็ปŸ่ฎกๆ–‡ไปถ: stats.json") + + # ๅˆ›ๅปบ็—…ไพ‹ๆ•ฐๆฎๅบ“ + cases = [ + { + "case_id": "case_0001", + "patient_id": "P0001", + "age": 48, + "gender": "็”ท", + "disease": "ๆคŽ้—ด็›˜็ชๅ‡บ", + "severity": "ไธญๅบฆ", + "image_path": "images/case_0001.jpg", + "report_path": "reports/case_0001_report.txt", + }, + { + "case_id": "case_0002", + "patient_id": "P0002", + "age": 60, + "gender": "็”ท", + "disease": "ๅคš่Š‚ๆฎต้€€่กŒๆ€งๅ˜", + "severity": "ไธญๅบฆ", + "image_path": "images/case_0002.jpg", + "report_path": "reports/case_0002_report.txt", + }, + ] + + all_cases_path = data_dir / "all_cases.json" + with open(all_cases_path, "w", encoding="utf-8") as f: + json.dump(cases, f, ensure_ascii=False, indent=2) + print(" โœ“ ๅˆ›ๅปบ็—…ไพ‹ๆ•ฐๆฎๅบ“: all_cases.json") + + print(f"\nโœ“ ็คบไพ‹ๆ•ฐๆฎๅˆ›ๅปบๅฎŒๆˆ๏ผŒไฟๅญ˜ๅœจ: {data_dir.absolute()}") + print() + + return str(data_dir) + + +def test_knowledge_loading(): + """ๆต‹่ฏ•็Ÿฅ่ฏ†ๅŠ ่ฝฝๅŠŸ่ƒฝ""" + print("=" * 80) + print("ๆต‹่ฏ•ๅŒปๅญฆ็Ÿฅ่ฏ†ๅบ“ - ไธไฝฟ็”จๆ•ฐๆฎ้›†") + print("=" * 80) + print() + + # ๆต‹่ฏ•1๏ผšไธไฝฟ็”จๆ•ฐๆฎ้›† + print("1๏ธโƒฃ ๆต‹่ฏ•ๅŸบๆœฌๅˆๅง‹ๅŒ–๏ผˆไป…ไฝฟ็”จ้ป˜่ฎค็Ÿฅ่ฏ†๏ผ‰") + print("-" * 80) + config = {} + kb = MedicalKnowledgeBase(config) + + print("\n๐Ÿ“Š ็Ÿฅ่ฏ†ๅบ“็ปŸ่ฎก:") + print(f" - ็Ÿฅ่ฏ†ๆก็›ฎๆ€ปๆ•ฐ: {len(kb.knowledge_base)}") + print(f" - ็—…ไพ‹ๆ•ฐๆฎๅบ“ๅคงๅฐ: {len(kb.case_database)}") + + print("\n๐Ÿ“š ้ป˜่ฎค็Ÿฅ่ฏ†ไธป้ข˜:") + for i, knowledge in enumerate(kb.knowledge_base[:5], 1): + topic = knowledge.get("topic", "Unknown") + source = knowledge.get("source", "default") + print(f" {i}. {topic} (ๆฅๆบ: {source})") + + print("\n" + "=" * 80) + print() + + # ๆต‹่ฏ•2๏ผšไฝฟ็”จ็คบไพ‹ๆ•ฐๆฎ + print("2๏ธโƒฃ ๆต‹่ฏ•ไปŽๆ•ฐๆฎ้›†ๅŠ ่ฝฝ็Ÿฅ่ฏ†") + print("-" * 80) + + # ๅˆ›ๅปบ็คบไพ‹ๆ•ฐๆฎ + data_path = create_sample_data() + + config_with_data = {"data_path": data_path} + kb_with_data = MedicalKnowledgeBase(config_with_data) + + print("\n๐Ÿ“Š ็Ÿฅ่ฏ†ๅบ“็ปŸ่ฎก๏ผˆไฝฟ็”จๆ•ฐๆฎ้›†๏ผ‰:") + print(f" - ็Ÿฅ่ฏ†ๆก็›ฎๆ€ปๆ•ฐ: {len(kb_with_data.knowledge_base)}") + print(f" - ็—…ไพ‹ๆ•ฐๆฎๅบ“ๅคงๅฐ: {len(kb_with_data.case_database)}") + + print("\n๐Ÿ“š ็Ÿฅ่ฏ†ไธป้ข˜:") + for i, knowledge in enumerate(kb_with_data.knowledge_base, 1): + topic = knowledge.get("topic", "Unknown") + source = knowledge.get("source", "default") + case_count = knowledge.get("case_count", "N/A") + if case_count != "N/A": + print(f" {i}. {topic} (ๆฅๆบ: {source}, ็—…ไพ‹ๆ•ฐ: {case_count})") + else: + print(f" {i}. {topic} (ๆฅๆบ: {source})") + + print("\n" + "=" * 80) + print() + + # ๆต‹่ฏ•3๏ผš็Ÿฅ่ฏ†ๆฃ€็ดข + print("3๏ธโƒฃ ๆต‹่ฏ•็Ÿฅ่ฏ†ๆฃ€็ดขๅŠŸ่ƒฝ") + print("-" * 80) + + queries = ["่…ฐๆคŽ้—ด็›˜็ชๅ‡บ", "้€€่กŒๆ€งๅ˜", "ๆคŽ็ฎก็‹ญ็ช„"] + + for query in queries: + results = kb_with_data.retrieve_knowledge(query, top_k=2) + print(f"\n๐Ÿ” ๆŸฅ่ฏข: '{query}'") + print(f" ๆ‰พๅˆฐ {len(results)} ๆก็›ธๅ…ณ็Ÿฅ่ฏ†:") + for i, result in enumerate(results, 1): + topic = result.get("topic", "Unknown") + print(f" {i}. {topic}") + + print("\n" + "=" * 80) + print() + + # ๆต‹่ฏ•4๏ผš็›ธไผผ็—…ไพ‹ๆฃ€็ดข + print("4๏ธโƒฃ ๆต‹่ฏ•็›ธไผผ็—…ไพ‹ๆฃ€็ดข") + print("-" * 80) + + queries_cases = ["ๆคŽ้—ด็›˜็ชๅ‡บ", "้€€่กŒๆ€งๅ˜", "่…ฐ็—›"] + + for query in queries_cases: + results = kb_with_data.retrieve_similar_cases(query, {}, top_k=3) + print(f"\n๐Ÿ” ๆŸฅ่ฏข: '{query}'") + print(f" ๆ‰พๅˆฐ {len(results)} ไธช็›ธไผผ็—…ไพ‹:") + for i, case in enumerate(results, 1): + case_id = case.get("case_id", "Unknown") + diagnosis = case.get("diagnosis", "Unknown") + score = case.get("similarity_score", 0.0) + print(f" {i}. {case_id}: {diagnosis} (็›ธไผผๅบฆ: {score:.2f})") + + print("\n" + "=" * 80) + print() + + # ๆธ…็†ๆต‹่ฏ•ๆ•ฐๆฎ + print("๐Ÿงน ๆธ…็†ๆต‹่ฏ•ๆ•ฐๆฎ...") + import shutil + + test_data_dir = Path(__file__).parent / "test_data" + if test_data_dir.exists(): + shutil.rmtree(test_data_dir) + print(" โœ“ ๆต‹่ฏ•ๆ•ฐๆฎๅทฒๅˆ ้™ค") + + print("\n" + "=" * 80) + print("โœ… ๆต‹่ฏ•ๅฎŒๆˆ!") + print("=" * 80) + + +if __name__ == "__main__": + test_knowledge_loading() diff --git a/apps/tests/medical_diagnosis/test_diagnosis.py b/apps/tests/medical_diagnosis/test_diagnosis.py new file mode 100644 index 0000000..0deeebc --- /dev/null +++ b/apps/tests/medical_diagnosis/test_diagnosis.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +ๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸ็ฎ€ๅ•ๆต‹่ฏ• +ไฝฟ็”จ้ข„ๅค„็†ๅฅฝ็š„ๆ•ฐๆฎๆต‹่ฏ•่ฏŠๆ–ญๆต็จ‹๏ผˆไธ้œ€่ฆ็œŸๅฎž็š„SAGEๆœๅŠก๏ผ‰ +""" + +import json +import os +import random +from pathlib import Path + +import pytest + +# ่Žทๅ–ๅŒป็–—่ฏŠๆ–ญ็›ฎๅฝ•่ทฏๅพ„๏ผˆ็”จไบŽ่ฎฟ้—ฎๆ•ฐๆฎๅ’Œ้…็ฝฎๆ–‡ไปถ๏ผ‰ +medical_diagnosis_dir = ( + Path(__file__).parent.parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" +) +# ๅฏผๅ…ฅๅŒป็–—่ฏŠๆ–ญๆจกๅ— +from sage.apps.medical_diagnosis.agents.diagnostic_agent import DiagnosticAgent # noqa: E402 + + +@pytest.mark.skipif( + os.getenv("SAGE_ENABLE_MEDICAL_DIAGNOSIS") not in {"1", "true", "True"}, + reason="Medical diagnosis integration test disabled by default; set SAGE_ENABLE_MEDICAL_DIAGNOSIS=1 to run.", +) +def test_single_case(): + """ๆต‹่ฏ•ๅ•ไธช็—…ไพ‹่ฏŠๆ–ญ""" + + print("=" * 80) + print("๐Ÿงช ๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸๆต‹่ฏ• - ๅ•็—…ไพ‹ๆจกๅผ") + print("=" * 80) + + # ๅŠ ่ฝฝๆต‹่ฏ•ๆ•ฐๆฎ + data_dir = medical_diagnosis_dir / "data" / "processed" + test_index_path = data_dir / "test_index.json" + + if not test_index_path.exists(): + pytest.skip( + f"Test data not available at {test_index_path}. Run scripts/prepare_data.py first to generate test data." + ) + + with open(test_index_path, encoding="utf-8") as f: + test_cases = json.load(f) + + # ้šๆœบ้€‰ๆ‹ฉไธ€ไธชๆต‹่ฏ•็—…ไพ‹ + case = random.choice(test_cases) + + print("\n๐Ÿ“‹ ๆต‹่ฏ•็—…ไพ‹ไฟกๆฏ:") + print(f" - ็—…ไพ‹ID: {case['case_id']}") + print(f" - ๆ‚ฃ่€…ID: {case['patient_id']}") + print(f" - ๅนด้พ„: {case['age']}ๅฒ") + print(f" - ๆ€งๅˆซ: {case['gender']}") + print(f" - ็œŸๅฎž็–พ็—…: {case['disease']}") + print(f" - ไธฅ้‡็จ‹ๅบฆ: {case['severity']}") + + # ๆž„ๅปบๅฎŒๆ•ด่ทฏๅพ„ + image_path = data_dir / case["image_path"] + + print(f"\n๐Ÿ“ธ MRIๅฝฑๅƒ: {image_path}") + print(f" (ๆ–‡ไปถๅญ˜ๅœจ: {image_path.exists()})") + + # ๅ‡†ๅค‡ๆ‚ฃ่€…ไฟกๆฏ + patient_info = { + "patient_id": case["patient_id"], + "age": case["age"], + "gender": case["gender"], + "symptoms": "่…ฐ็—›ไผดไธ‹่‚ขๆ”พๅฐ„็—›" if case["severity"] != "ๆ— " else "ไฝ“ๆฃ€ๅ‘็Žฐ", + } + + # ๅˆๅง‹ๅŒ–่ฏŠๆ–ญ็ณป็ปŸ + print("\n๐Ÿ”ง ๅˆๅง‹ๅŒ–่ฏŠๆ–ญ็ณป็ปŸ...") + + config_path = medical_diagnosis_dir / "config" / "agent_config.yaml" + + try: + # ๅˆ›ๅปบ่ฏŠๆ–ญAgent + agent = DiagnosticAgent(config_path=str(config_path)) + print(" โœ“ DiagnosticAgent ๅˆๅง‹ๅŒ–ๆˆๅŠŸ") + + # ๆ‰ง่กŒ่ฏŠๆ–ญ + print("\n๐Ÿ” ๅผ€ๅง‹่ฏŠๆ–ญ...") + result = agent.diagnose(image_path=str(image_path), patient_info=patient_info) + + # ๆ˜พ็คบ็ป“ๆžœ + print("\n" + "=" * 80) + print("๐Ÿ“Š ่ฏŠๆ–ญ็ป“ๆžœๆ‘˜่ฆ") + print("=" * 80) + + print(f"\n็—…ไพ‹ID: {case['patient_id']}") + print(f"่ฏŠๆ–ญๆ—ถ้—ด: {result.timestamp}") + print("\nๅฝฑๅƒๅˆ†ๆž:") + print(f" - ่ดจ้‡่ฏ„ๅˆ†: {result.quality_score:.2f}") + print(f" - ๆฃ€ๆต‹ๅˆฐ็š„็—…ๅ˜: {len(result.findings)} ๅค„") + + print("\nไธป่ฆๅ‘็Žฐ:") + for finding in result.findings[:3]: + print(f" - {finding}") + + print("\n่ฏŠๆ–ญ็ป“่ฎบ:") + for i, diagnosis in enumerate(result.diagnoses, 1): + print(f" {i}. {diagnosis}") + + print(f"\n ็ฝฎไฟกๅบฆ: {result.confidence:.2%}") + + print(f"\n็›ธไผผ็—…ไพ‹ๅ‚่€ƒ: {len(result.similar_cases)} ไธช") + + print("\n๐Ÿ“„ ๅฎŒๆ•ด่ฏŠๆ–ญๆŠฅๅ‘Š:") + print("-" * 80) + print(result.report) + print("-" * 80) + + # ๅฏนๆฏ”็œŸๅฎžๆ ‡็ญพ + print(f"\nโœ… ็œŸๅฎž็–พ็—…: {case['disease']}") + print(f" ่ฏŠๆ–ญ็ป“ๆžœไธญๆ˜ฏๅฆๅŒ…ๅซ: {'ๆ˜ฏ' if case['disease'] in result.report else 'ๅฆ'}") + + except Exception as e: + print(f"โŒ ่ฏŠๆ–ญ่ฟ‡็จ‹ๅ‡บ้”™: {e}") + import traceback + + traceback.print_exc() + + print("\n" + "=" * 80) + + +@pytest.mark.skipif( + os.getenv("SAGE_ENABLE_MEDICAL_DIAGNOSIS") not in {"1", "true", "True"}, + reason="Medical diagnosis integration test disabled by default; set SAGE_ENABLE_MEDICAL_DIAGNOSIS=1 to run.", +) +def test_batch_mode(): + """ๆต‹่ฏ•ๆ‰น้‡่ฏŠๆ–ญๆจกๅผ""" + + print("=" * 80) + print("๐Ÿงช ๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸๆต‹่ฏ• - ๆ‰น้‡ๆจกๅผ") + print("=" * 80) + + # ๅŠ ่ฝฝๆต‹่ฏ•ๆ•ฐๆฎ + data_dir = medical_diagnosis_dir / "data" / "processed" + test_index_path = data_dir / "test_index.json" + + if not test_index_path.exists(): + pytest.skip( + f"Test data not available at {test_index_path}. Run scripts/prepare_data.py first to generate test data." + ) + + with open(test_index_path, encoding="utf-8") as f: + test_cases = json.load(f) + + # ้€‰ๆ‹ฉๅ‰5ไธช็—…ไพ‹ + cases_to_test = test_cases[:5] + + print(f"\n๐Ÿ“‹ ๅ‡†ๅค‡ๆ‰น้‡่ฏŠๆ–ญ {len(cases_to_test)} ไธช็—…ไพ‹...") + + # ๅ‡†ๅค‡ๆ‰น้‡็—…ไพ‹ + batch_cases = [] + for case in cases_to_test: + image_path = data_dir / case["image_path"] + patient_info = { + "patient_id": case["patient_id"], + "age": case["age"], + "gender": case["gender"], + "symptoms": "่…ฐ็—›" if case["severity"] != "ๆ— " else "ไฝ“ๆฃ€", + } + batch_cases.append( + { + "image_path": str(image_path), + "patient_info": patient_info, + } + ) + + # ๅˆๅง‹ๅŒ–Agent + config_path = medical_diagnosis_dir / "config" / "agent_config.yaml" + agent = DiagnosticAgent(config_path=str(config_path)) + + # ๆ‰น้‡่ฏŠๆ–ญ + print("\n๐Ÿ” ๅผ€ๅง‹ๆ‰น้‡่ฏŠๆ–ญ...") + output_dir = medical_diagnosis_dir / "data" / "test_results" + + results = agent.batch_diagnose(cases=batch_cases, output_dir=str(output_dir)) + + # ๆ˜พ็คบ็ป“ๆžœๆ‘˜่ฆ + print("\n" + "=" * 80) + print("๐Ÿ“Š ๆ‰น้‡่ฏŠๆ–ญ็ป“ๆžœๆ‘˜่ฆ") + print("=" * 80) + + for i, result in enumerate(results, 1): + print(f"\n{i}. ็—…ไพ‹ {cases_to_test[i - 1]['case_id']}") + print(f" ่ฏŠๆ–ญ: {', '.join(result.diagnoses)}") + print(f" ็ฝฎไฟกๅบฆ: {result.confidence:.2%}") + print(f" ๅ‘็Žฐ: {len(result.findings)} ๅค„") + + print(f"\nโœ… ๆ‰น้‡่ฏŠๆ–ญๅฎŒๆˆ๏ผŒ็ป“ๆžœๅทฒไฟๅญ˜ๅˆฐ: {output_dir}") + print("=" * 80) + + +def main(): + """ไธปๅ‡ฝๆ•ฐ""" + + import argparse + + parser = argparse.ArgumentParser(description="ๅŒป็–—่ฏŠๆ–ญ็ณป็ปŸๆต‹่ฏ•") + parser.add_argument( + "--mode", + type=str, + default="single", + choices=["single", "batch"], + help="ๆต‹่ฏ•ๆจกๅผ: single(ๅ•็—…ไพ‹) ๆˆ– batch(ๆ‰น้‡)", + ) + + args = parser.parse_args() + + if args.mode == "single": + test_single_case() + elif args.mode == "batch": + test_batch_mode() + + +if __name__ == "__main__": + main() diff --git a/apps/tests/medical_diagnosis/test_image_analyzer.py b/apps/tests/medical_diagnosis/test_image_analyzer.py new file mode 100644 index 0000000..777b927 --- /dev/null +++ b/apps/tests/medical_diagnosis/test_image_analyzer.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Tests for ImageAnalyzer image loading and feature extraction.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from sage.apps.medical_diagnosis.agents.image_analyzer import TORCH_AVAILABLE, ImageAnalyzer + + +# --------------------------------------------------------------------------- +# Common fixtures +# --------------------------------------------------------------------------- +@pytest.fixture +def analyzer(): + """Create an ImageAnalyzer instance for image-loading tests.""" + config = {"models": {"vision_model": "test-model"}} + return ImageAnalyzer(config) + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for test files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def basic_config(): + """Configuration used for feature extraction tests.""" + return { + "models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}, + "image_processing": {"feature_extraction": {"method": "clip", "dimension": 768}}, + } + + +@pytest.fixture +def dinov2_config(): + """Configuration for testing DINOv2 feature extraction.""" + return { + "models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}, + "image_processing": {"feature_extraction": {"method": "dinov2", "dimension": 768}}, + } + + +@pytest.fixture +def mock_image(): + """Create a mock grayscale numpy image.""" + return np.random.randint(0, 255, (512, 512), dtype=np.uint8) + + +@pytest.fixture +def mock_pil_image(): + """Create a mock RGB PIL image for feature extraction tests.""" + if not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + from PIL import Image + + return Image.fromarray(np.random.randint(0, 255, (512, 512, 3), dtype=np.uint8)) + + +# --------------------------------------------------------------------------- +# Image loading unit tests +# --------------------------------------------------------------------------- +class TestLoadImage: + """Tests for the _load_image method.""" + + def test_load_image_nonexistent_file(self, analyzer, temp_dir): + fake_path = temp_dir / "nonexistent.png" + result = analyzer._load_image(fake_path) + assert result is None + + def test_load_image_dicom_extension(self, analyzer, temp_dir): + dcm_path = temp_dir / "test.dcm" + dcm_path.touch() + + with patch.object(analyzer, "_load_dicom_image", return_value=None) as mock_dicom: + analyzer._load_image(dcm_path) + mock_dicom.assert_called_once_with(dcm_path) + + def test_load_image_dicom_extension_uppercase(self, analyzer, temp_dir): + dcm_path = temp_dir / "test.DCM" + dcm_path.touch() + + with patch.object(analyzer, "_load_dicom_image", return_value=None) as mock_dicom: + analyzer._load_image(dcm_path) + mock_dicom.assert_called_once_with(dcm_path) + + def test_load_image_png_extension(self, analyzer, temp_dir): + png_path = temp_dir / "test.png" + png_path.touch() + + with patch.object(analyzer, "_load_regular_image", return_value=None) as mock_regular: + analyzer._load_image(png_path) + mock_regular.assert_called_once_with(png_path) + + def test_load_image_jpg_extension(self, analyzer, temp_dir): + jpg_path = temp_dir / "test.jpg" + jpg_path.touch() + + with patch.object(analyzer, "_load_regular_image", return_value=None) as mock_regular: + analyzer._load_image(jpg_path) + mock_regular.assert_called_once_with(jpg_path) + + +class TestLoadDicomImage: + """Tests for the _load_dicom_image method.""" + + def test_load_dicom_success(self, analyzer, temp_dir): + dcm_path = temp_dir / "test.dcm" + dcm_path.touch() + + mock_dicom = MagicMock() + mock_dicom.pixel_array = np.array([[100, 200], [150, 250]], dtype=np.int16) + del mock_dicom.WindowCenter + del mock_dicom.WindowWidth + + with patch.dict("sys.modules", {"pydicom": MagicMock()}): + import sys + + sys.modules["pydicom"].dcmread.return_value = mock_dicom + result = analyzer._load_dicom_image(dcm_path) + + assert result is not None + assert result.dtype == np.uint8 + assert result.shape == (2, 2) + assert result.min() >= 0 + assert result.max() <= 255 + + def test_load_dicom_with_window_level(self, analyzer, temp_dir): + dcm_path = temp_dir / "test.dcm" + dcm_path.touch() + + mock_dicom = MagicMock() + mock_dicom.pixel_array = np.array([[0, 500], [1000, 2000]], dtype=np.int16) + mock_dicom.WindowCenter = 500 + mock_dicom.WindowWidth = 1000 + + with patch.dict("sys.modules", {"pydicom": MagicMock()}): + import sys + + sys.modules["pydicom"].dcmread.return_value = mock_dicom + result = analyzer._load_dicom_image(dcm_path) + + assert result is not None + assert result.dtype == np.uint8 + + def test_load_dicom_pydicom_not_installed(self, analyzer, temp_dir): + dcm_path = temp_dir / "test.dcm" + dcm_path.touch() + + with patch.dict("sys.modules", {"pydicom": None}): + original_import = __import__ + + def mock_import(name, *args, **kwargs): + if name == "pydicom": + raise ImportError("No module named 'pydicom'") + return original_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=mock_import): + result = analyzer._load_dicom_image(dcm_path) + + assert result is None + + def test_load_dicom_invalid_file(self, analyzer, temp_dir): + dcm_path = temp_dir / "invalid.dcm" + dcm_path.write_bytes(b"not a valid dicom file") + + result = analyzer._load_dicom_image(dcm_path) + assert result is None or isinstance(result, np.ndarray) + + +class TestLoadRegularImage: + """Tests for the _load_regular_image method.""" + + def test_load_regular_image_png(self, analyzer, temp_dir): + png_path = temp_dir / "test.png" + + try: + from PIL import Image + + img = Image.new("RGB", (10, 10), color="red") + img.save(png_path) + result = analyzer._load_regular_image(png_path) + except ImportError: + pytest.skip("PIL not installed") + + assert result is not None + assert isinstance(result, np.ndarray) + assert result.shape == (10, 10) + assert result.dtype == np.uint8 + + def test_load_regular_image_jpg(self, analyzer, temp_dir): + jpg_path = temp_dir / "test.jpg" + + try: + from PIL import Image + + img = Image.new("L", (20, 20), color=128) + img.save(jpg_path) + result = analyzer._load_regular_image(jpg_path) + except ImportError: + pytest.skip("PIL not installed") + + assert result is not None + assert isinstance(result, np.ndarray) + assert result.shape == (20, 20) + assert result.dtype == np.uint8 + + def test_load_regular_image_grayscale_conversion(self, analyzer, temp_dir): + png_path = temp_dir / "color.png" + + try: + from PIL import Image + + img = Image.new("RGB", (5, 5), color=(255, 0, 0)) + img.save(png_path) + result = analyzer._load_regular_image(png_path) + except ImportError: + pytest.skip("PIL not installed") + + assert result is not None + assert len(result.shape) == 2 + + def test_load_regular_image_invalid_file(self, analyzer, temp_dir): + invalid_path = temp_dir / "invalid.png" + invalid_path.write_bytes(b"not a valid image file") + + result = analyzer._load_regular_image(invalid_path) + assert result is None + + +class TestImageAnalyzerLoadingIntegration: + """Integration tests for the image loading pipeline.""" + + def test_analyze_with_valid_image(self, analyzer, temp_dir): + try: + from PIL import Image + + img_path = temp_dir / "mri_test.png" + img = Image.new("L", (100, 100), color=128) + img.save(img_path) + result = analyzer.analyze(str(img_path)) + except ImportError: + pytest.skip("PIL not installed") + + assert "vertebrae" in result + assert "discs" in result + assert "abnormalities" in result + assert "image_quality" in result + assert result["image_quality"] > 0 + + def test_analyze_with_nonexistent_image(self, analyzer, temp_dir): + fake_path = temp_dir / "nonexistent.png" + result = analyzer.analyze(str(fake_path)) + + assert "vertebrae" in result + assert "discs" in result + assert "abnormalities" in result + + def test_image_quality_assessment(self, analyzer, temp_dir): + try: + from PIL import Image + + img_path = temp_dir / "quality_test.png" + img = Image.new("L", (50, 50), color=100) + img.save(img_path) + image = analyzer._load_image(Path(img_path)) + quality = analyzer._assess_quality(image) + except ImportError: + pytest.skip("PIL not installed") + + assert 0.0 <= quality <= 1.0 + + def test_image_quality_none_image(self, analyzer): + assert analyzer._assess_quality(None) == 0.0 + + +# --------------------------------------------------------------------------- +# Feature extraction tests from main-dev +# --------------------------------------------------------------------------- +class TestImageAnalyzerFeatureExtraction: + """Unit tests for ImageAnalyzer feature extraction.""" + + def test_initialization_without_torch(self, basic_config): + with patch("sage.apps.medical_diagnosis.agents.image_analyzer.TORCH_AVAILABLE", False): + analyzer = ImageAnalyzer(basic_config) + assert analyzer.feature_model is None + assert analyzer.feature_processor is None + + def test_initialization_with_clip(self, basic_config): + if not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + with ( + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPProcessor") as mock_proc, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPModel") as mock_model, + ): + mock_proc.from_pretrained.return_value = MagicMock() + mock_model.from_pretrained.return_value = MagicMock() + analyzer = ImageAnalyzer(basic_config) + + assert analyzer.feature_method == "clip" + + def test_initialization_with_dinov2(self, dinov2_config): + if not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + with ( + patch( + "sage.apps.medical_diagnosis.agents.image_analyzer.AutoImageProcessor" + ) as mock_proc, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.AutoModel") as mock_model, + ): + mock_proc.from_pretrained.return_value = MagicMock() + mock_model.from_pretrained.return_value = MagicMock() + analyzer = ImageAnalyzer(dinov2_config) + + assert analyzer.feature_method == "dinov2" + + def test_mock_features_fallback(self, basic_config, mock_image): + with patch("sage.apps.medical_diagnosis.agents.image_analyzer.TORCH_AVAILABLE", False): + analyzer = ImageAnalyzer(basic_config) + features = analyzer._extract_features(mock_image) + + assert features is not None + assert isinstance(features, np.ndarray) + assert features.shape == (768,) + assert features.dtype == np.float32 + + def test_extract_features_with_none_image(self, basic_config): + analyzer = ImageAnalyzer(basic_config) + assert analyzer._extract_features(None) is None + + def test_extract_features_with_numpy_array(self, basic_config, mock_image): + with patch("sage.apps.medical_diagnosis.agents.image_analyzer.TORCH_AVAILABLE", False): + analyzer = ImageAnalyzer(basic_config) + features = analyzer._extract_features(mock_image) + + assert features is not None + assert isinstance(features, np.ndarray) + assert len(features.shape) == 1 + + def test_extract_features_error_handling(self, basic_config, mock_image): + if not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + with ( + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPProcessor") as mock_proc, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPModel") as mock_model, + ): + mock_proc.from_pretrained.return_value = MagicMock() + mock_model_instance = MagicMock() + mock_model_instance.cuda.return_value = mock_model_instance + mock_model_instance.get_image_features.side_effect = RuntimeError("Model error") + mock_model.from_pretrained.return_value = mock_model_instance + analyzer = ImageAnalyzer(basic_config) + features = analyzer._extract_features(mock_image) + + assert features is not None + assert isinstance(features, np.ndarray) + + def test_feature_dimension_config(self, mock_image): + custom_config = { + "models": {"vision_model": "test"}, + "image_processing": {"feature_extraction": {"method": "clip", "dimension": 512}}, + } + + with patch("sage.apps.medical_diagnosis.agents.image_analyzer.TORCH_AVAILABLE", False): + analyzer = ImageAnalyzer(custom_config) + features = analyzer._extract_features(mock_image) + + assert features.shape == (512,) + + def test_analyze_with_nonexistent_image(self, basic_config): + analyzer = ImageAnalyzer(basic_config) + result = analyzer.analyze("nonexistent_image.jpg") + + assert "vertebrae" in result + assert "discs" in result + assert "abnormalities" in result + assert "image_quality" in result + assert "image_embedding" in result + + @pytest.mark.skipif(not TORCH_AVAILABLE, reason="PyTorch not available") + def test_clip_feature_extraction_shape(self, basic_config, mock_pil_image): + with ( + patch( + "sage.apps.medical_diagnosis.agents.image_analyzer.CLIPProcessor" + ) as mock_proc_cls, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPModel") as mock_model_cls, + ): + import torch + + mock_processor = MagicMock() + mock_processor.return_value = {"pixel_values": torch.randn(1, 3, 224, 224)} + mock_proc_cls.from_pretrained.return_value = mock_processor + + mock_model = MagicMock() + mock_features = torch.randn(1, 512) + mock_model.get_image_features.return_value = mock_features + mock_model_cls.from_pretrained.return_value = mock_model + + analyzer = ImageAnalyzer(basic_config) + analyzer.feature_processor = mock_processor + analyzer.feature_model = mock_model + features = analyzer._extract_clip_features(mock_pil_image) + + assert isinstance(features, np.ndarray) + assert features.dtype == np.float32 + assert len(features.shape) == 1 + assert np.isclose(np.linalg.norm(features), 1.0, rtol=1e-5) + + @pytest.mark.skipif(not TORCH_AVAILABLE, reason="PyTorch not available") + def test_dinov2_feature_extraction_shape(self, dinov2_config, mock_pil_image): + with ( + patch( + "sage.apps.medical_diagnosis.agents.image_analyzer.AutoImageProcessor" + ) as mock_proc_cls, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.AutoModel") as mock_model_cls, + ): + import torch + + mock_processor = MagicMock() + mock_processor.return_value = {"pixel_values": torch.randn(1, 3, 224, 224)} + mock_proc_cls.from_pretrained.return_value = mock_processor + + mock_model = MagicMock() + mock_output = MagicMock() + mock_output.last_hidden_state = torch.randn(1, 197, 768) + mock_model.return_value = mock_output + mock_model_cls.from_pretrained.return_value = mock_model + + analyzer = ImageAnalyzer(dinov2_config) + analyzer.feature_processor = mock_processor + analyzer.feature_model = mock_model + features = analyzer._extract_dinov2_features(mock_pil_image) + + assert isinstance(features, np.ndarray) + assert features.dtype == np.float32 + assert len(features.shape) == 1 + assert np.isclose(np.linalg.norm(features), 1.0, rtol=1e-5) + + def test_unknown_feature_method(self, mock_image): + config = { + "models": {"vision_model": "test"}, + "image_processing": {"feature_extraction": {"method": "unknown", "dimension": 768}}, + } + + analyzer = ImageAnalyzer(config) + features = analyzer._extract_features(mock_image) + + assert features is not None + assert features.shape == (768,) + + +class TestImageAnalyzerFeatureIntegration: + """Integration-style tests focusing on feature extraction pipeline.""" + + def test_full_analysis_pipeline(self, basic_config, tmp_path): + if not TORCH_AVAILABLE: + pytest.skip("PyTorch not available") + + from PIL import Image + + test_image_path = tmp_path / "test_mri.png" + test_image = Image.fromarray( + np.random.randint(0, 255, (512, 512), dtype=np.uint8), mode="L" + ) + test_image.save(test_image_path) + + with ( + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPProcessor") as mock_proc, + patch("sage.apps.medical_diagnosis.agents.image_analyzer.CLIPModel") as mock_model, + ): + mock_proc.from_pretrained.return_value = MagicMock() + mock_model.from_pretrained.return_value = MagicMock() + analyzer = ImageAnalyzer(basic_config) + result = analyzer.analyze(str(test_image_path)) + + assert "vertebrae" in result + assert "discs" in result + assert "abnormalities" in result + assert "image_quality" in result + assert "image_embedding" in result + assert result["image_path"] == str(test_image_path) + assert len(result["vertebrae"]) == 5 + assert len(result["discs"]) == 5 + + def test_create_mock_analysis(self, basic_config): + analyzer = ImageAnalyzer(basic_config) + result = analyzer._create_mock_analysis() + + assert isinstance(result["vertebrae"], list) + assert isinstance(result["discs"], list) + assert isinstance(result["abnormalities"], list) + assert isinstance(result["image_quality"], float) + assert isinstance(result["image_embedding"], np.ndarray) + assert result["image_embedding"].shape == (768,) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/apps/tests/medical_diagnosis/test_image_quality.py b/apps/tests/medical_diagnosis/test_image_quality.py new file mode 100644 index 0000000..ce8d071 --- /dev/null +++ b/apps/tests/medical_diagnosis/test_image_quality.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +ๆต‹่ฏ•ๅฝฑๅƒ่ดจ้‡่ฏ„ไผฐ็ฎ—ๆณ• +""" + +import numpy as np +import pytest + +from sage.apps.medical_diagnosis.agents.image_analyzer import ImageAnalyzer + + +class TestImageQualityAssessment: + """ๆต‹่ฏ•ๅฝฑๅƒ่ดจ้‡่ฏ„ไผฐๅŠŸ่ƒฝ""" + + @pytest.fixture + def analyzer(self): + """ๅˆ›ๅปบImageAnalyzerๅฎžไพ‹""" + config = {"models": {"vision_model": "test-model"}} + return ImageAnalyzer(config) + + def test_assess_quality_none_image(self, analyzer): + """ๆต‹่ฏ•Noneๅ›พๅƒ็š„่ดจ้‡่ฏ„ไผฐ""" + score = analyzer._assess_quality(None) + assert score == 0.0, "Noneๅ›พๅƒๅบ”่ฟ”ๅ›ž0.0ๅˆ†ๆ•ฐ" + + def test_assess_quality_high_quality_image(self, analyzer): + """ๆต‹่ฏ•้ซ˜่ดจ้‡ๅ›พๅƒ๏ผˆ้ซ˜ๅฏนๆฏ”ๅบฆใ€ๆธ…ๆ™ฐ๏ผ‰""" + # ๅˆ›ๅปบไธ€ไธชๆธ…ๆ™ฐ็š„้ซ˜ๅฏนๆฏ”ๅบฆๅ›พๅƒ๏ผˆๆฃ‹็›˜ๆ ผ๏ผ‰ + size = 100 + image = np.zeros((size, size), dtype=np.uint8) + # ๅˆ›ๅปบๆฃ‹็›˜ๆ ผๆจกๅผ + for i in range(size): + for j in range(size): + if (i // 10 + j // 10) % 2 == 0: + image[i, j] = 255 + + score = analyzer._assess_quality(image) + # ้ซ˜่ดจ้‡ๅ›พๅƒๅบ”่ฏฅๆœ‰่พƒ้ซ˜ๅˆ†ๆ•ฐ + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + assert score > 0.6, f"้ซ˜่ดจ้‡ๅ›พๅƒๅบ”ๆœ‰่พƒ้ซ˜ๅˆ†ๆ•ฐ (>0.6)๏ผŒๅฎž้™…: {score}" + + def test_assess_quality_low_quality_blurry_image(self, analyzer): + """ๆต‹่ฏ•ไฝŽ่ดจ้‡ๅ›พๅƒ๏ผˆๆจก็ณŠ๏ผ‰""" + # ๅˆ›ๅปบๆจก็ณŠๅ›พๅƒ๏ผˆๅ‡ๅŒ€็ฐๅบฆ๏ผ‰ + size = 100 + image = np.ones((size, size), dtype=np.uint8) * 128 + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + # ๆจก็ณŠๅ›พๅƒๅบ”่ฏฅๆœ‰่พƒไฝŽๅˆ†ๆ•ฐ๏ผˆๅฏนๆฏ”ๅบฆไฝŽใ€ๆธ…ๆ™ฐๅบฆไฝŽ๏ผ‰ + assert score < 0.8, f"ๆจก็ณŠๅ›พๅƒๅบ”ๆœ‰่พƒไฝŽๅˆ†ๆ•ฐ (<0.8)๏ผŒๅฎž้™…: {score}" + + def test_assess_quality_noisy_image(self, analyzer): + """ๆต‹่ฏ•ๅ™ชๅฃฐๅ›พๅƒ""" + # ๅˆ›ๅปบ้šๆœบๅ™ชๅฃฐๅ›พๅƒ + size = 100 + np.random.seed(42) + image = np.random.randint(0, 256, (size, size), dtype=np.uint8) + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + # ๅ™ชๅฃฐๅ›พๅƒๅฏ่ƒฝๆœ‰ไธๅŒๅˆ†ๆ•ฐ๏ผŒไธป่ฆๆต‹่ฏ•ไธไผšๅดฉๆบƒ + + def test_assess_quality_dark_image(self, analyzer): + """ๆต‹่ฏ•ๆš—ๅ›พๅƒ๏ผˆไบฎๅบฆ่ฟ‡ไฝŽ๏ผ‰""" + # ๅˆ›ๅปบๆš—ๅ›พๅƒ + size = 100 + image = np.ones((size, size), dtype=np.uint8) * 30 # ๅพˆๆš— + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + # ไบฎๅบฆไธไฝณ็š„ๅ›พๅƒๅˆ†ๆ•ฐๅบ”่ฏฅๅ—ๅฝฑๅ“ + + def test_assess_quality_bright_image(self, analyzer): + """ๆต‹่ฏ•ไบฎๅ›พๅƒ๏ผˆไบฎๅบฆ่ฟ‡้ซ˜๏ผ‰""" + # ๅˆ›ๅปบไบฎๅ›พๅƒ + size = 100 + image = np.ones((size, size), dtype=np.uint8) * 240 # ๅพˆไบฎ + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + + def test_assess_quality_normalized_image(self, analyzer): + """ๆต‹่ฏ•ๅทฒๅฝ’ไธ€ๅŒ–ๅ›พๅƒ๏ผˆ0-1่Œƒๅ›ด๏ผ‰""" + # ๅˆ›ๅปบ0-1่Œƒๅ›ด็š„ๅ›พๅƒ + size = 100 + image = np.random.rand(size, size).astype(np.float32) + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + + def test_assess_quality_edge_cases(self, analyzer): + """ๆต‹่ฏ•่พน็ผ˜ๆƒ…ๅ†ต""" + # ๆต‹่ฏ•ๅ•ๅƒ็ด ๅ›พๅƒ + small_image = np.array([[128]], dtype=np.uint8) + score = analyzer._assess_quality(small_image) + assert 0.0 <= score <= 1.0, "ๅ•ๅƒ็ด ๅ›พๅƒๅบ”่ฟ”ๅ›žๆœ‰ๆ•ˆๅˆ†ๆ•ฐ" + + # ๆต‹่ฏ•ๅ…จ้ป‘ๅ›พๅƒ + black_image = np.zeros((50, 50), dtype=np.uint8) + score = analyzer._assess_quality(black_image) + assert 0.0 <= score <= 1.0, "ๅ…จ้ป‘ๅ›พๅƒๅบ”่ฟ”ๅ›žๆœ‰ๆ•ˆๅˆ†ๆ•ฐ" + + # ๆต‹่ฏ•ๅ…จ็™ฝๅ›พๅƒ + white_image = np.ones((50, 50), dtype=np.uint8) * 255 + score = analyzer._assess_quality(white_image) + assert 0.0 <= score <= 1.0, "ๅ…จ็™ฝๅ›พๅƒๅบ”่ฟ”ๅ›žๆœ‰ๆ•ˆๅˆ†ๆ•ฐ" + + def test_assess_quality_realistic_medical_image(self, analyzer): + """ๆต‹่ฏ•ๆจกๆ‹Ÿ็œŸๅฎžๅŒปๅญฆๅฝฑๅƒ""" + # ๅˆ›ๅปบๆจกๆ‹ŸMRIๅฝฑๅƒ๏ผˆๅธฆๆœ‰็ป“ๆž„ๅ’Œๆธๅ˜๏ผ‰ + size = 200 + image = np.zeros((size, size), dtype=np.uint8) + + # ๆทปๅŠ ่ƒŒๆ™ฏๆธๅ˜ + for i in range(size): + for j in range(size): + image[i, j] = int(100 + 50 * np.sin(i / 20) * np.cos(j / 20)) + + # ๆทปๅŠ ไธ€ไบ›"็ป“ๆž„"๏ผˆๆจกๆ‹ŸๆคŽไฝ“๏ผ‰ + for k in range(5): + y_center = 40 + k * 30 + for i in range(max(0, y_center - 15), min(size, y_center + 15)): + for j in range(80, 120): + dist = np.sqrt((i - y_center) ** 2 + (j - 100) ** 2) + if dist < 15: + image[i, j] = 180 + + score = analyzer._assess_quality(image) + assert 0.0 <= score <= 1.0, f"ๅˆ†ๆ•ฐๅบ”ๅœจ[0,1]่Œƒๅ›ดๅ†…๏ผŒๅฎž้™…: {score}" + # ๆœ‰็ป“ๆž„็š„ๅ›พๅƒๅบ”่ฏฅๆœ‰ๅˆ็†็š„ๅˆ†ๆ•ฐ + assert 0.3 <= score <= 0.9, f"ๆจกๆ‹ŸๅŒปๅญฆๅฝฑๅƒๅบ”ๆœ‰ๅˆ็†ๅˆ†ๆ•ฐ๏ผŒๅฎž้™…: {score}" + + def test_assess_quality_consistency(self, analyzer): + """ๆต‹่ฏ•่ฏ„ไผฐไธ€่‡ดๆ€ง๏ผˆๅŒไธ€ๅ›พๅƒๅคšๆฌก่ฏ„ไผฐๅบ”ๅพ—ๅˆฐ็›ธๅŒ็ป“ๆžœ๏ผ‰""" + size = 100 + image = np.random.RandomState(42).randint(0, 256, (size, size), dtype=np.uint8) + + score1 = analyzer._assess_quality(image) + score2 = analyzer._assess_quality(image) + + assert score1 == score2, "ๅŒไธ€ๅ›พๅƒ็š„่ฏ„ไผฐๅบ”่ฏฅไธ€่‡ด" + + def test_assess_quality_different_types(self, analyzer): + """ๆต‹่ฏ•ไธๅŒๆ•ฐๆฎ็ฑปๅž‹็š„ๅ›พๅƒ""" + size = 50 + + # uint8 ็ฑปๅž‹ + img_uint8 = np.random.RandomState(42).randint(0, 256, (size, size), dtype=np.uint8) + score_uint8 = analyzer._assess_quality(img_uint8) + assert 0.0 <= score_uint8 <= 1.0 + + # float32 ็ฑปๅž‹ (0-1) + img_float32 = img_uint8.astype(np.float32) / 255.0 + score_float32 = analyzer._assess_quality(img_float32) + assert 0.0 <= score_float32 <= 1.0 + + # ไธค็ง็ฑปๅž‹ๅบ”่ฏฅ็ป™ๅ‡บ็›ธ่ฟ‘็š„็ป“ๆžœ + assert abs(score_uint8 - score_float32) < 0.1, "ไธๅŒ็ฑปๅž‹็›ธๅŒๅ†…ๅฎนๅบ”็ป™ๅ‡บ็›ธ่ฟ‘ๅˆ†ๆ•ฐ" + + def test_quality_penalizes_added_noise(self, analyzer): + """ๅนฒๅ‡€ๅ›พๅƒๅบ”ๆฏ”ๅŠ ๅ™ชๅ›พๅƒ่Žทๅพ—ๆ›ด้ซ˜ๅˆ†""" + size = 128 + gradient = np.tile(np.linspace(30, 220, size, dtype=np.float32), (size, 1)) + clean = gradient.astype(np.uint8) + rng = np.random.RandomState(7) + noisy = np.clip(clean + rng.normal(0, 25, clean.shape), 0, 255).astype(np.uint8) + + clean_score = analyzer._assess_quality(clean) + noisy_score = analyzer._assess_quality(noisy) + + assert clean_score > noisy_score, "ๅŠ ๅ™ชๅŽๅบ”้™ไฝŽ่ดจ้‡ๅˆ†ๆ•ฐ" + + def test_brightness_histogram_balance(self, analyzer): + """ไบฎๅบฆ็›ดๆ–นๅ›พๅนณ่กก็š„ๅ›พๅƒๅบ”ไผ˜ไบŽ่ฟ‡ๆ›/ๆฌ ๆ›ๅ›พๅƒ""" + size = 200 + balanced_line = np.linspace(0, 255, size, dtype=np.float32) + balanced = np.tile(balanced_line, (size, 1)).astype(np.uint8) + bright = np.ones((size, size), dtype=np.uint8) * 240 + dark = np.ones((size, size), dtype=np.uint8) * 15 + + balanced_score = analyzer._assess_quality(balanced) + bright_score = analyzer._assess_quality(bright) + dark_score = analyzer._assess_quality(dark) + + assert balanced_score > bright_score + assert balanced_score > dark_score diff --git a/apps/tests/medical_diagnosis/test_knowledge_base.py b/apps/tests/medical_diagnosis/test_knowledge_base.py new file mode 100644 index 0000000..b32a586 --- /dev/null +++ b/apps/tests/medical_diagnosis/test_knowledge_base.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +""" +Tests for MedicalKnowledgeBase +""" + +import json +from pathlib import Path + +import pytest + +from sage.apps.medical_diagnosis.tools.knowledge_base import MedicalKnowledgeBase + + +class TestMedicalKnowledgeBase: + """Test MedicalKnowledgeBase class""" + + def test_initialization_basic(self): + """Test basic initialization without data files""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + assert kb is not None + assert isinstance(kb.knowledge_base, list) + assert len(kb.knowledge_base) > 0 # Should have at least default knowledge + assert isinstance(kb.case_database, list) + + def test_default_knowledge_loaded(self): + """Test that default knowledge is loaded""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + # Check that default knowledge contains expected topics + topics = [k["topic"] for k in kb.knowledge_base] + assert "่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡" in topics + assert "่…ฐๆคŽ้€€่กŒๆ€งๅ˜" in topics + assert "ๆคŽ็ฎก็‹ญ็ช„" in topics + + def test_retrieve_knowledge_basic(self): + """Test basic knowledge retrieval""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + # Search for knowledge about disc herniation + results = kb.retrieve_knowledge("่…ฐๆคŽ้—ด็›˜็ชๅ‡บ", top_k=3) + + assert isinstance(results, list) + # Should find at least one result + assert len(results) >= 1 + # First result should be relevant + assert "่…ฐๆคŽ" in results[0]["topic"] or "ๆคŽ้—ด็›˜" in results[0]["topic"] + + def test_retrieve_similar_cases_basic(self): + """Test basic case retrieval""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + # Search for similar cases + results = kb.retrieve_similar_cases(query="่…ฐ็—›ไผดไธ‹่‚ข้บปๆœจ", image_features={}, top_k=3) + + assert isinstance(results, list) + assert len(results) > 0 + assert len(results) <= 3 + # Each result should have required fields + for case in results: + assert "case_id" in case + assert "diagnosis" in case + + def test_add_case(self): + """Test adding a new case""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + initial_count = len(kb.case_database) + + new_case = { + "case_id": "TEST_001", + "age": 50, + "gender": "male", + "diagnosis": "่…ฐๆคŽ้—ด็›˜็ชๅ‡บ็—‡", + } + + kb.add_case(new_case) + + assert len(kb.case_database) == initial_count + 1 + assert kb.case_database[-1]["case_id"] == "TEST_001" + + def test_update_knowledge(self): + """Test updating knowledge base""" + config = {"verbose": False} + kb = MedicalKnowledgeBase(config) + + initial_count = len(kb.knowledge_base) + + new_knowledge = { + "topic": "ๆต‹่ฏ•็–พ็—…", + "content": "่ฟ™ๆ˜ฏไธ€ไธชๆต‹่ฏ•็–พ็—…็š„ๆ่ฟฐ", + "treatment": "ๆต‹่ฏ•ๆฒป็–—ๆ–นๆณ•", + } + + kb.update_knowledge(new_knowledge) + + assert len(kb.knowledge_base) == initial_count + 1 + assert kb.knowledge_base[-1]["topic"] == "ๆต‹่ฏ•็–พ็—…" + + def test_load_from_nonexistent_path(self): + """Test loading with nonexistent data path""" + config = {"data_path": "/nonexistent/path/to/data", "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Should still initialize with default knowledge + assert len(kb.knowledge_base) >= 3 + assert len(kb.case_database) == 0 + + def test_configuration_options(self): + """Test configuration options work correctly""" + config = { + "verbose": False, + "enable_dataset_knowledge": False, + "enable_report_knowledge": False, + "enable_case_database": False, + } + kb = MedicalKnowledgeBase(config) + + # Should only have default knowledge (3 entries) + assert len(kb.knowledge_base) == 3 + assert len(kb.case_database) == 0 + + def test_max_reports_configuration(self): + """Test max_reports configuration option""" + config = { + "verbose": False, + "max_reports": 10, + } + kb = MedicalKnowledgeBase(config) + + # Should initialize without error + assert kb._max_reports == 10 + + @pytest.mark.skipif( + not ( + Path(__file__).parent.parent.parent + / "src" + / "sage" + / "apps" + / "medical_diagnosis" + / "data" + / "processed" + / "stats.json" + ).exists(), + reason="Test data not available", + ) + def test_load_from_dataset(self): + """Test loading knowledge from actual dataset if available""" + # Point to the actual data directory + medical_dir = ( + Path(__file__).parent.parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" + ) + data_path = medical_dir / "data" / "processed" + + config = {"data_path": str(data_path), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Should have loaded additional knowledge + assert len(kb.knowledge_base) > 3 # More than just default knowledge + + @pytest.mark.skipif( + not ( + Path(__file__).parent.parent.parent + / "src" + / "sage" + / "apps" + / "medical_diagnosis" + / "data" + / "processed" + / "all_cases.json" + ).exists(), + reason="Test data not available", + ) + def test_load_case_database(self): + """Test loading case database from dataset if available""" + # Point to the actual data directory + medical_dir = ( + Path(__file__).parent.parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" + ) + data_path = medical_dir / "data" / "processed" + + config = {"data_path": str(data_path), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Should have loaded cases + assert len(kb.case_database) > 0 + + # Test case retrieval with loaded cases + results = kb.retrieve_similar_cases(query="ๆคŽ้—ด็›˜็ชๅ‡บ", image_features={}, top_k=3) + + assert len(results) > 0 + + +class TestMedicalKnowledgeBaseWithMockData: + """Test MedicalKnowledgeBase with mock data files""" + + @pytest.fixture + def mock_data_dir(self, tmp_path): + """Create a mock data directory with test files""" + # Create processed data directory + processed_dir = tmp_path / "processed" + processed_dir.mkdir() + + # Create stats.json + stats = { + "total_samples": 100, + "disease_distribution": { + "ๆคŽ้—ด็›˜็ชๅ‡บ": 30, + "ๆคŽ็ฎก็‹ญ็ช„": 20, + "ๆญฃๅธธ": 50, # This should be filtered out + }, + "severity_distribution": { + "่ฝปๅบฆ": 40, + "ไธญๅบฆ": 35, + "้‡ๅบฆ": 25, + }, + } + with open(processed_dir / "stats.json", "w", encoding="utf-8") as f: + json.dump(stats, f, ensure_ascii=False) + + # Create all_cases.json + cases = [ + { + "case_id": "case_0001", + "patient_id": "P0001", + "age": 45, + "gender": "็”ท", + "disease": "ๆคŽ้—ด็›˜็ชๅ‡บ", + "severity": "ไธญๅบฆ", + "image_path": "images/case_0001.jpg", + "report_path": "reports/case_0001_report.txt", + }, + { + "case_id": "case_0002", + "patient_id": "P0002", + "age": 55, + "gender": "ๅฅณ", + "disease": "ๆคŽ็ฎก็‹ญ็ช„", + "severity": "้‡ๅบฆ", + "image_path": "images/case_0002.jpg", + "report_path": "reports/case_0002_report.txt", + }, + ] + with open(processed_dir / "all_cases.json", "w", encoding="utf-8") as f: + json.dump(cases, f, ensure_ascii=False) + + # Create reports directory with mock reports + reports_dir = processed_dir / "reports" + reports_dir.mkdir() + + # Create mock reports + report_1 = """ๆ‚ฃ่€…ไฟกๆฏ: + ๅนด้พ„: 45ๅฒ + ๆ€งๅˆซ: ็”ท + ไธป่ฏ‰: ่…ฐ็—›ไผดๅณไธ‹่‚ขๆ”พๅฐ„็—›3ๅ‘จ + +ๅฝฑๅƒๆ่ฟฐ: + ่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: L4/L5ๆคŽ้—ด็›˜ๅ‘ๅŽ็ชๅ‡บ,ๅŽ‹่ฟซ็กฌ่†œๅ›Šใ€‚ + +ไธป่ฆๅ‘็Žฐ: + - ็—…ๅ˜่Š‚ๆฎต: L4/L5 + - ็—…ๅ˜็ฑปๅž‹: ๆคŽ้—ด็›˜็ชๅ‡บ + - ไธฅ้‡็จ‹ๅบฆ: ไธญๅบฆ + +่ฏŠๆ–ญ็ป“่ฎบ: + ๆคŽ้—ด็›˜็ชๅ‡บ๏ผŒ็จ‹ๅบฆไธญๅบฆใ€‚ + +ๆฒป็–—ๅปบ่ฎฎ: + ๅปบ่ฎฎๅงๅบŠไผ‘ๆฏ2-3ๅ‘จ๏ผŒ็‰ตๅผ•ๆฒป็–—ใ€‚ๅฃๆœ้ž็”พไฝ“ๆŠ—็‚Ž่ฏๅŠ็ฅž็ป่ฅๅ…ป่ฏ็‰ฉใ€‚ +""" + with open(reports_dir / "case_0001_report.txt", "w", encoding="utf-8") as f: + f.write(report_1) + + report_2 = """ๆ‚ฃ่€…ไฟกๆฏ: + ๅนด้พ„: 55ๅฒ + ๆ€งๅˆซ: ๅฅณ + ไธป่ฏ‰: ่…ฐ็—›ไผดๅŒไธ‹่‚ข้บปๆœจใ€ๆ— ๅŠ›2ๆœˆ + +ๅฝฑๅƒๆ่ฟฐ: + ่…ฐๆคŽMRI T2ๅŠ ๆƒ็Ÿข็Šถไฝ: L4/L5ๆคŽ็ฎก็‹ญ็ช„,็กฌ่†œๅ›Šๅ—ๅŽ‹ใ€‚ + +ไธป่ฆๅ‘็Žฐ: + - ็—…ๅ˜่Š‚ๆฎต: L4/L5 + - ็—…ๅ˜็ฑปๅž‹: ๆคŽ็ฎก็‹ญ็ช„ + - ไธฅ้‡็จ‹ๅบฆ: ้‡ๅบฆ + +่ฏŠๆ–ญ็ป“่ฎบ: + ๆคŽ็ฎก็‹ญ็ช„๏ผŒ็จ‹ๅบฆ้‡ๅบฆใ€‚ + +ๆฒป็–—ๅปบ่ฎฎ: + ๅปบ่ฎฎๅฐฝๆ—ฉๆ‰‹ๆœฏๆฒป็–—(ๆคŽ้—ด็›˜ๆ‘˜้™คๆœฏๆˆ–ๆคŽ็ฎกๅ‡ๅŽ‹ๆœฏ)๏ผŒไปฅ่งฃ้™ค็ฅž็ปๅŽ‹่ฟซใ€‚ +""" + with open(reports_dir / "case_0002_report.txt", "w", encoding="utf-8") as f: + f.write(report_2) + + return processed_dir + + def test_load_knowledge_from_mock_dataset(self, mock_data_dir): + """Test loading knowledge from mock stats.json""" + config = {"data_path": str(mock_data_dir), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Should have default knowledge (3) + dataset knowledge (2, excluding "ๆญฃๅธธ") + topics = [k["topic"] for k in kb.knowledge_base] + assert "ๆคŽ้—ด็›˜็ชๅ‡บ" in topics + assert "ๆคŽ็ฎก็‹ญ็ช„" in topics + assert "ๆญฃๅธธ" not in topics # "ๆญฃๅธธ" should be filtered out + + def test_load_knowledge_from_mock_reports(self, mock_data_dir): + """Test loading knowledge from mock report files""" + config = {"data_path": str(mock_data_dir), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Check that report knowledge was loaded + report_knowledge = [k for k in kb.knowledge_base if k.get("source") == "medical_reports"] + assert len(report_knowledge) >= 1 + + # Check content of extracted knowledge + for knowledge in report_knowledge: + assert "topic" in knowledge + assert "content" in knowledge + assert "treatment" in knowledge + + def test_load_case_database_from_mock(self, mock_data_dir): + """Test loading case database from mock all_cases.json""" + config = {"data_path": str(mock_data_dir), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Should have loaded 2 cases + assert len(kb.case_database) == 2 + + # Check case structure + for case in kb.case_database: + assert "case_id" in case + assert "age" in case + assert "gender" in case + assert "diagnosis" in case + assert "severity" in case + + def test_retrieve_cases_from_mock_database(self, mock_data_dir): + """Test case retrieval with mock database""" + config = {"data_path": str(mock_data_dir), "verbose": False} + kb = MedicalKnowledgeBase(config) + + # Search for cases related to disc herniation + results = kb.retrieve_similar_cases(query="ๆคŽ้—ด็›˜็ชๅ‡บ", image_features={}, top_k=3) + + assert len(results) >= 1 + # At least one result should be about disc herniation + assert any("ๆคŽ้—ด็›˜็ชๅ‡บ" in r.get("diagnosis", "") for r in results) + + def test_disable_dataset_loading(self, mock_data_dir): + """Test that dataset loading can be disabled""" + config = { + "data_path": str(mock_data_dir), + "verbose": False, + "enable_dataset_knowledge": False, + } + kb = MedicalKnowledgeBase(config) + + # Should not have knowledge from dataset_statistics source + dataset_knowledge = [ + k for k in kb.knowledge_base if k.get("source") == "dataset_statistics" + ] + assert len(dataset_knowledge) == 0 + + def test_disable_report_loading(self, mock_data_dir): + """Test that report loading can be disabled""" + config = { + "data_path": str(mock_data_dir), + "verbose": False, + "enable_report_knowledge": False, + } + kb = MedicalKnowledgeBase(config) + + # Should not have knowledge from medical_reports source + report_knowledge = [k for k in kb.knowledge_base if k.get("source") == "medical_reports"] + assert len(report_knowledge) == 0 + + def test_disable_case_database_loading(self, mock_data_dir): + """Test that case database loading can be disabled""" + config = { + "data_path": str(mock_data_dir), + "verbose": False, + "enable_case_database": False, + } + kb = MedicalKnowledgeBase(config) + + # Should not have loaded any cases + assert len(kb.case_database) == 0 + + def test_max_reports_limit(self, mock_data_dir): + """Test max_reports configuration limits report loading""" + config = { + "data_path": str(mock_data_dir), + "verbose": False, + "max_reports": 1, # Only read 1 report + } + kb = MedicalKnowledgeBase(config) + + # Should have limited number of report knowledge entries + report_knowledge = [k for k in kb.knowledge_base if k.get("source") == "medical_reports"] + # With max_reports=1, we should have at most 1 unique disease from reports + assert len(report_knowledge) <= 1 diff --git a/apps/tests/smart_home/__init__.py b/apps/tests/smart_home/__init__.py new file mode 100644 index 0000000..a39b843 --- /dev/null +++ b/apps/tests/smart_home/__init__.py @@ -0,0 +1 @@ +"""Tests for Smart Home application""" diff --git a/apps/tests/smart_home/test_operators.py b/apps/tests/smart_home/test_operators.py new file mode 100644 index 0000000..1fe5966 --- /dev/null +++ b/apps/tests/smart_home/test_operators.py @@ -0,0 +1,377 @@ +"""Unit tests for Smart Home Application + +Tests for sage.apps.smart_home module +""" + +import pytest + +from sage.apps.smart_home.operators import ( + DeviceEvent, + DeviceExecutor, + DeviceType, + EnvironmentMonitor, + EventLogSink, + LaundryWorkflowSource, + TaskStatus, + WorkflowProgressSink, +) + + +class TestDeviceEvent: + """Test DeviceEvent dataclass""" + + def test_event_creation(self): + """Test creating a device event""" + event = DeviceEvent( + event_id=1, + device_id="robot_01", + device_type=DeviceType.ROBOT, + event_type="task_started", + timestamp="2024-01-15T10:00:00", + data={"task": "pickup_laundry", "status": "running"}, + ) + + assert event.event_id == 1 + assert event.device_id == "robot_01" + assert event.device_type == DeviceType.ROBOT + assert event.event_type == "task_started" + assert "task" in event.data + + def test_event_with_sensor_data(self): + """Test event with sensor data""" + event = DeviceEvent( + event_id=2, + device_id="humidity_01", + device_type=DeviceType.HUMIDITY_SENSOR, + event_type="reading", + timestamp="2024-01-15T10:00:01", + data={"humidity": 65.0, "temperature": 22.5}, + ) + + assert event.device_type == DeviceType.HUMIDITY_SENSOR + assert event.data["humidity"] == 65.0 + + +class TestDeviceType: + """Test DeviceType enum""" + + def test_device_types(self): + """Test all device types are defined""" + assert DeviceType.ROBOT.value == "robot" + assert DeviceType.WASHER.value == "washer" + assert DeviceType.DRYER.value == "dryer" + assert DeviceType.HUMIDITY_SENSOR.value == "humidity_sensor" + assert DeviceType.MOTION_SENSOR.value == "motion_sensor" + + +class TestTaskStatus: + """Test TaskStatus enum""" + + def test_task_statuses(self): + """Test all task statuses are defined""" + assert TaskStatus.PENDING.value == "pending" + assert TaskStatus.RUNNING.value == "running" + assert TaskStatus.COMPLETED.value == "completed" + assert TaskStatus.FAILED.value == "failed" + + +class TestLaundryWorkflowSource: + """Test LaundryWorkflowSource operator""" + + def test_source_creation(self): + """Test creating laundry workflow source""" + source = LaundryWorkflowSource(num_cycles=2) + + assert source.num_cycles == 2 + assert source.current_cycle == 0 + assert source.step == 0 + + def test_source_generates_workflow_tasks(self): + """Test source generates workflow tasks""" + source = LaundryWorkflowSource(num_cycles=1) + + # Execute to get first task + task = source.execute() + + # Should generate workflow task + assert task is not None + assert "task" in task + assert "device" in task + assert "cycle" in task + assert task["cycle"] == 1 + + def test_source_workflow_steps(self): + """Test source generates all workflow steps""" + source = LaundryWorkflowSource(num_cycles=1) + + tasks = [] + while True: + task = source.execute() + if task is None: + break + tasks.append(task) + + # Should have 6 steps in workflow + assert len(tasks) == 6 + assert tasks[0]["task"] == "check_environment" + assert tasks[1]["task"] == "collect_laundry" + assert tasks[2]["task"] == "wash" + + def test_source_with_multiple_cycles(self): + """Test source with multiple laundry cycles""" + source = LaundryWorkflowSource(num_cycles=2) + + tasks = [] + while True: + task = source.execute() + if task is None: + break + tasks.append(task) + + # Should have 6 steps * 2 cycles = 12 tasks + assert len(tasks) == 12 + assert tasks[0]["cycle"] == 1 + assert tasks[6]["cycle"] == 2 + + +class TestDeviceExecutor: + """Test DeviceExecutor operator""" + + def test_executor_creation(self): + """Test creating device executor""" + executor = DeviceExecutor() + + assert executor.event_counter == 0 + + def test_execute_robot_task(self): + """Test executing robot task""" + executor = DeviceExecutor() + + task_data = { + "task": "collect_laundry", + "device": "robot_001", + "description": "Robot collects laundry", + "params": {"from": "basket", "to": "washer"}, + "duration": 0.01, + } + + result = executor.execute(task_data) + assert result is not None + assert result["status"] == TaskStatus.COMPLETED.value + assert "completed_at" in result + assert "event_id" in result + + def test_execute_washer_task(self): + """Test executing washer task""" + executor = DeviceExecutor() + + task_data = { + "task": "wash", + "device": "washer_001", + "description": "Wash laundry", + "duration": 0.01, + } + + result = executor.execute(task_data) + assert result is not None + assert result["status"] == TaskStatus.COMPLETED.value + assert result["success"] is True + + def test_execute_dryer_task(self): + """Test executing dryer task""" + executor = DeviceExecutor() + + task_data = { + "task": "dry", + "device": "dryer_001", + "description": "Dry laundry", + "duration": 0.01, + } + + result = executor.execute(task_data) + assert result is not None + assert result["status"] == TaskStatus.COMPLETED.value + assert result["success"] is True + + def test_execute_humidity_sensor(self): + """Test executing humidity sensor task""" + executor = DeviceExecutor() + + task_data = { + "task": "check_environment", + "device": "humid_sensor_001", + "description": "Check humidity", + "duration": 0.01, + } + + result = executor.execute(task_data) + assert result is not None + assert "humidity" in result + assert DeviceExecutor.MIN_HUMIDITY <= result["humidity"] <= DeviceExecutor.MAX_HUMIDITY + + +class TestEnvironmentMonitor: + """Test EnvironmentMonitor operator""" + + def test_monitor_creation(self): + """Test creating environment monitor""" + monitor = EnvironmentMonitor() + + assert monitor is not None + + def test_monitor_check_environment_optimal(self): + """Test monitor with optimal humidity""" + monitor = EnvironmentMonitor() + + task_data = {"task": "check_environment", "humidity": 55.0} + + result = monitor.execute(task_data) + assert result is not None + assert result["environment_status"] == "optimal" + + def test_monitor_check_environment_too_dry(self): + """Test monitor with low humidity""" + monitor = EnvironmentMonitor() + + task_data = {"task": "check_environment", "humidity": 35.0} + + result = monitor.execute(task_data) + assert result is not None + assert result["environment_status"] == "too_dry" + + def test_monitor_check_environment_too_humid(self): + """Test monitor with high humidity""" + monitor = EnvironmentMonitor() + + task_data = {"task": "check_environment", "humidity": 75.0} + + result = monitor.execute(task_data) + assert result is not None + assert result["environment_status"] == "too_humid" + + def test_monitor_non_environment_task(self): + """Test monitor passes through non-environment tasks""" + monitor = EnvironmentMonitor() + + task_data = {"task": "wash", "device": "washer_001"} + + result = monitor.execute(task_data) + assert result is not None + assert result["task"] == "wash" + + +class TestWorkflowProgressSink: + """Test WorkflowProgressSink operator""" + + def test_sink_creation(self): + """Test creating workflow progress sink""" + sink = WorkflowProgressSink() + + assert sink.tasks_completed == 0 + assert sink.total_steps == 0 + + def test_sink_collects_task(self): + """Test sink collects workflow task""" + sink = WorkflowProgressSink() + + task_data = { + "task": "collect_laundry", + "device": "robot_001", + "cycle": 1, + "step": 2, + "total_steps": 6, + } + + sink.execute(task_data) + assert sink.tasks_completed == 1 + assert sink.total_steps == 6 + + def test_sink_tracks_multiple_tasks(self): + """Test sink tracks multiple workflow tasks""" + sink = WorkflowProgressSink() + + tasks = [ + {"task": "check_environment", "cycle": 1, "step": i + 1, "total_steps": 6} + for i in range(6) + ] + + for task in tasks: + sink.execute(task) + + assert sink.tasks_completed == 6 + + +class TestEventLogSink: + """Test EventLogSink operator""" + + def test_sink_creation(self): + """Test creating event log sink""" + sink = EventLogSink() + + assert len(sink.events) == 0 + + def test_sink_logs_event(self): + """Test sink logs device event""" + sink = EventLogSink() + + event_data = { + "task": "wash", + "device": "washer_001", + "status": TaskStatus.COMPLETED.value, + } + + sink.execute(event_data) + assert len(sink.events) == 1 + assert sink.events[0]["device"] == "washer_001" + + def test_sink_logs_multiple_events(self): + """Test sink logs multiple events""" + sink = EventLogSink() + + events = [{"task": f"task_{i}", "device": "robot_001", "step": i} for i in range(10)] + + for event in events: + sink.execute(event) + + assert len(sink.events) == 10 + + +@pytest.mark.integration +class TestSmartHomePipeline: + """Integration tests for smart home laundry workflow""" + + def test_workflow_execution(self): + """Test complete laundry workflow execution""" + # Create operators + source = LaundryWorkflowSource(num_cycles=1) + executor = DeviceExecutor() + monitor = EnvironmentMonitor() + progress_sink = WorkflowProgressSink() + log_sink = EventLogSink() + + # Execute workflow + while True: + task = source.execute() + if task is None: + break + + # Execute task + result = executor.execute(task) + + # Monitor environment + monitored = monitor.execute(result) + + # Collect results + progress_sink.execute(monitored) + log_sink.execute(monitored) + + # Verify completion + assert progress_sink.tasks_completed == 6 + assert len(log_sink.events) == 6 + + @pytest.mark.skip(reason="Pipeline infrastructure test") + def test_pipeline_creation(self): + """Test creating the smart home pipeline""" + # Placeholder for pipeline creation test + # Would require actual pipeline module + pass diff --git a/apps/tests/test_medical_diagnosis.py b/apps/tests/test_medical_diagnosis.py new file mode 100644 index 0000000..907589d --- /dev/null +++ b/apps/tests/test_medical_diagnosis.py @@ -0,0 +1,136 @@ +""" +Tests for medical diagnosis application +""" + +import importlib.util +from pathlib import Path + +import pytest + + +class TestMedicalDiagnosisStructure: + """Test medical diagnosis app structure""" + + @pytest.fixture + def medical_dir(self): + """Get medical diagnosis directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" + + def test_medical_directory_exists(self, medical_dir): + """Verify medical diagnosis directory exists""" + assert medical_dir.exists(), "Medical diagnosis directory should exist" + + def test_required_files_exist(self, medical_dir): + """Verify required files exist""" + required_files = [ + "__init__.py", + "README.md", + "run_diagnosis.py", + ] + + for file_name in required_files: + file_path = medical_dir / file_name + assert file_path.exists(), f"Required file not found: {file_name}" + + def test_required_directories_exist(self, medical_dir): + """Verify required directories exist""" + required_dirs = [ + "agents", + "config", + "tools", + ] + + for dir_name in required_dirs: + dir_path = medical_dir / dir_name + assert dir_path.exists(), f"Required directory not found: {dir_name}" + + def test_run_diagnosis_not_empty(self, medical_dir): + """Verify run_diagnosis.py is not empty""" + run_file = medical_dir / "run_diagnosis.py" + content = run_file.read_text() + assert len(content.strip()) > 0, "run_diagnosis.py should not be empty" + + def test_run_diagnosis_has_main_or_class(self, medical_dir): + """Verify run_diagnosis.py has executable code""" + run_file = medical_dir / "run_diagnosis.py" + content = run_file.read_text() + + # Check if file has either main() function or class definition + has_main = "def main(" in content or "def main (" in content + has_class = "class " in content + has_if_name = 'if __name__ == "__main__"' in content + + assert has_main or has_class or has_if_name, ( + "run_diagnosis.py should have main() function, class definition, or if __name__ == '__main__'" + ) + + +class TestMedicalDiagnosisImports: + """Test medical diagnosis imports""" + + @pytest.fixture + def medical_dir(self): + """Get medical diagnosis directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" + + def test_run_diagnosis_imports(self, medical_dir): + """Test run_diagnosis.py can be imported""" + run_file = medical_dir / "run_diagnosis.py" + + spec = importlib.util.spec_from_file_location("run_diagnosis", run_file) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + # Module loaded successfully + assert True + except ImportError as e: + # Import errors are expected if dependencies are missing + pytest.skip(f"Skipping due to missing dependencies: {e}") + except Exception as e: + pytest.fail(f"Failed to import run_diagnosis.py: {e}") + + def test_agents_directory_has_content(self, medical_dir): + """Verify agents directory has Python files""" + agents_dir = medical_dir / "agents" + python_files = list(agents_dir.glob("*.py")) + + # Should have at least __init__.py + assert len(python_files) > 0, "agents directory should have Python files" + + def test_tools_directory_has_content(self, medical_dir): + """Verify tools directory has Python files""" + tools_dir = medical_dir / "tools" + python_files = list(tools_dir.glob("*.py")) + + # Should have at least __init__.py + assert len(python_files) > 0, "tools directory should have Python files" + + +class TestMedicalDiagnosisConfig: + """Test medical diagnosis configuration""" + + @pytest.fixture + def config_dir(self): + """Get config directory path""" + return ( + Path(__file__).parent.parent / "src" / "sage" / "apps" / "medical_diagnosis" / "config" + ) + + def test_config_directory_exists(self, config_dir): + """Verify config directory exists""" + assert config_dir.exists(), "Config directory should exist" + + def test_config_files_exist(self, config_dir): + """Verify config files exist""" + config_files = ( + list(config_dir.glob("*.yaml")) + + list(config_dir.glob("*.json")) + + list(config_dir.glob("*.toml")) + ) + + # Should have at least one config file + if len(config_files) > 0: + assert True, "Config directory has configuration files" + else: + pytest.skip("No config files found (might be optional)") diff --git a/apps/tests/test_package.py b/apps/tests/test_package.py new file mode 100644 index 0000000..e4706f6 --- /dev/null +++ b/apps/tests/test_package.py @@ -0,0 +1,17 @@ +"""Test sage.apps package metadata.""" + +from sage.apps import __version__ + + +def test_version(): + """Test package version is defined.""" + assert __version__ is not None + assert isinstance(__version__, str) + assert len(__version__) > 0 + + +def test_package_imports(): + """Test basic package imports work.""" + import sage.apps + + assert hasattr(sage.apps, "__version__") diff --git a/apps/tests/test_video_app.py b/apps/tests/test_video_app.py new file mode 100644 index 0000000..435822e --- /dev/null +++ b/apps/tests/test_video_app.py @@ -0,0 +1,160 @@ +""" +Tests for video application +""" + +import importlib.util +from pathlib import Path + +import pytest + + +class TestVideoAppStructure: + """Test video app structure""" + + @pytest.fixture + def video_dir(self): + """Get video directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "video" + + def test_video_directory_exists(self, video_dir): + """Verify video directory exists""" + assert video_dir.exists(), "Video directory should exist" + + def test_required_files_exist(self, video_dir): + """Verify required files exist""" + required_files = [ + "__init__.py", + "video_intelligence_pipeline.py", + ] + + for file_name in required_files: + file_path = video_dir / file_name + assert file_path.exists(), f"Required file not found: {file_name}" + + def test_required_directories_exist(self, video_dir): + """Verify required directories exist""" + required_dirs = [ + "operators", + "config", + ] + + for dir_name in required_dirs: + dir_path = video_dir / dir_name + assert dir_path.exists(), f"Required directory not found: {dir_name}" + + def test_video_intelligence_pipeline_not_empty(self, video_dir): + """Verify video_intelligence_pipeline.py is not empty""" + pipeline_file = video_dir / "video_intelligence_pipeline.py" + content = pipeline_file.read_text() + assert len(content.strip()) > 0, "video_intelligence_pipeline.py should not be empty" + + def test_video_intelligence_has_main_or_class(self, video_dir): + """Verify video_intelligence_pipeline.py has executable code""" + pipeline_file = video_dir / "video_intelligence_pipeline.py" + content = pipeline_file.read_text() + + # Check if file has either main() function or class definition + has_main = "def main(" in content or "def main (" in content + has_class = "class " in content + has_if_name = 'if __name__ == "__main__"' in content + + assert has_main or has_class or has_if_name, ( + "video_intelligence_pipeline.py should have main() function, class definition, or if __name__ == '__main__'" + ) + + +class TestVideoAppImports: + """Test video app imports""" + + @pytest.fixture + def video_dir(self): + """Get video directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "video" + + def test_video_intelligence_pipeline_imports(self, video_dir): + """Test video_intelligence_pipeline.py can be imported""" + pipeline_file = video_dir / "video_intelligence_pipeline.py" + + spec = importlib.util.spec_from_file_location("video_intelligence_pipeline", pipeline_file) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + # Module loaded successfully + assert True + except ImportError as e: + # Import errors are expected if dependencies are missing + pytest.skip(f"Skipping due to missing dependencies: {e}") + except Exception as e: + pytest.fail(f"Failed to import video_intelligence_pipeline.py: {e}") + + def test_operators_directory_has_content(self, video_dir): + """Verify operators directory has Python files""" + operators_dir = video_dir / "operators" + python_files = list(operators_dir.glob("*.py")) + + # Should have at least __init__.py + assert len(python_files) > 0, "operators directory should have Python files" + + +class TestVideoAppOperators: + """Test video app operators""" + + @pytest.fixture + def operators_dir(self): + """Get operators directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "video" / "operators" + + def test_operators_not_empty(self, operators_dir): + """Verify operator files are not empty""" + python_files = list(operators_dir.glob("*.py")) + python_files = [f for f in python_files if f.name != "__init__.py"] + + for py_file in python_files: + content = py_file.read_text() + assert len(content.strip()) > 0, f"{py_file.name} should not be empty" + + def test_operators_have_classes(self, operators_dir): + """Verify operator files have class definitions""" + python_files = list(operators_dir.glob("*.py")) + python_files = [f for f in python_files if f.name != "__init__.py"] + + if len(python_files) == 0: + pytest.skip("No operator files found") + + for py_file in python_files: + content = py_file.read_text() + + # Check if file has class definition + has_class = "class " in content + + # Operator files should typically have class definitions + if not has_class: + pytest.skip(f"{py_file.name} might be a utility file without classes") + + +class TestVideoAppConfig: + """Test video app configuration""" + + @pytest.fixture + def config_dir(self): + """Get config directory path""" + return Path(__file__).parent.parent / "src" / "sage" / "apps" / "video" / "config" + + def test_config_directory_exists(self, config_dir): + """Verify config directory exists""" + assert config_dir.exists(), "Config directory should exist" + + def test_config_files_exist(self, config_dir): + """Verify config files exist""" + config_files = ( + list(config_dir.glob("*.yaml")) + + list(config_dir.glob("*.json")) + + list(config_dir.glob("*.toml")) + ) + + # Should have at least one config file + if len(config_files) > 0: + assert True, "Config directory has configuration files" + else: + pytest.skip("No config files found (might be optional)") diff --git a/apps/tests/work_report_generator/__init__.py b/apps/tests/work_report_generator/__init__.py new file mode 100644 index 0000000..8b727a0 --- /dev/null +++ b/apps/tests/work_report_generator/__init__.py @@ -0,0 +1 @@ +"""Tests for Work Report Generator module""" diff --git a/apps/tests/work_report_generator/test_models.py b/apps/tests/work_report_generator/test_models.py new file mode 100644 index 0000000..6fda280 --- /dev/null +++ b/apps/tests/work_report_generator/test_models.py @@ -0,0 +1,296 @@ +"""Unit tests for Work Report Generator data models. + +Tests for sage.apps.work_report_generator.models module. +""" + +from sage.apps.work_report_generator.models import ( + ContributorSummary, + DiaryEntry, + GitHubCommit, + GitHubPullRequest, + WeeklyReport, +) + + +class TestGitHubCommit: + """Test GitHubCommit dataclass.""" + + def test_commit_creation(self): + """Test creating a commit.""" + commit = GitHubCommit( + sha="abc123def456", # pragma: allowlist secret + message="feat: Add new feature", + author="developer1", + author_email="dev1@example.com", + committed_date="2024-01-15T10:00:00Z", + repo="intellistream/SAGE", + url="https://github.com/intellistream/SAGE/commit/abc123def456", + additions=100, + deletions=50, + changed_files=5, + ) + + assert commit.sha == "abc123def456" # pragma: allowlist secret + assert commit.message == "feat: Add new feature" + assert commit.author == "developer1" + assert commit.additions == 100 + assert commit.deletions == 50 + + def test_commit_from_graphql(self): + """Test creating commit from GraphQL response.""" + graphql_data = { + "oid": "abc123", + "message": "fix: Bug fix\n\nDetailed description", + "committedDate": "2024-01-15T10:00:00Z", + "url": "https://github.com/test/repo/commit/abc123", + "additions": 10, + "deletions": 5, + "changedFiles": 2, + "author": { + "name": "Test User", + "email": "test@example.com", + "user": {"login": "testuser"}, + }, + } + + commit = GitHubCommit.from_graphql(graphql_data, "test/repo") + + assert commit.sha == "abc123" + assert commit.message == "fix: Bug fix" # Only first line + assert commit.author == "testuser" + assert commit.author_email == "test@example.com" + assert commit.repo == "test/repo" + assert commit.additions == 10 + + def test_commit_from_graphql_missing_user(self): + """Test creating commit when user info is missing.""" + graphql_data = { + "oid": "def456", + "message": "Update file", + "committedDate": "2024-01-15T10:00:00Z", + "url": "https://github.com/test/repo/commit/def456", + "additions": 5, + "deletions": 2, + "changedFiles": 1, + "author": { + "name": "External User", + "email": "external@example.com", + "user": None, + }, + } + + commit = GitHubCommit.from_graphql(graphql_data, "test/repo") + + assert commit.sha == "def456" + assert commit.author == "External User" + + +class TestGitHubPullRequest: + """Test GitHubPullRequest dataclass.""" + + def test_pr_creation(self): + """Test creating a pull request.""" + pr = GitHubPullRequest( + number=100, + title="feat: Add weekly report generator", + author="developer1", + state="MERGED", + created_at="2024-01-15T10:00:00Z", + merged_at="2024-01-16T10:00:00Z", + closed_at=None, + repo="intellistream/SAGE", + url="https://github.com/intellistream/SAGE/pull/100", + additions=500, + deletions=100, + changed_files=15, + labels=["feature", "enhancement"], + reviewers=["reviewer1"], + ) + + assert pr.number == 100 + assert pr.state == "MERGED" + assert len(pr.labels) == 2 + assert pr.merged_at is not None + + def test_pr_from_graphql(self): + """Test creating PR from GraphQL response.""" + graphql_data = { + "number": 42, + "title": "Fix: Resolve issue", + "state": "MERGED", + "createdAt": "2024-01-15T10:00:00Z", + "mergedAt": "2024-01-16T10:00:00Z", + "closedAt": None, + "url": "https://github.com/test/repo/pull/42", + "additions": 20, + "deletions": 10, + "changedFiles": 3, + "author": {"login": "developer"}, + "labels": {"nodes": [{"name": "bug"}, {"name": "priority:high"}]}, + "reviewRequests": {"nodes": []}, + } + + pr = GitHubPullRequest.from_graphql(graphql_data, "test/repo") + + assert pr.number == 42 + assert pr.title == "Fix: Resolve issue" + assert pr.author == "developer" + assert pr.state == "MERGED" + assert "bug" in pr.labels + + +class TestDiaryEntry: + """Test DiaryEntry dataclass.""" + + def test_diary_creation(self): + """Test creating a diary entry.""" + entry = DiaryEntry( + date="2024-01-15", + author="developer1", + content="Today I worked on the report generator.", + tags=["development", "feature"], + category="work", + ) + + assert entry.date == "2024-01-15" + assert entry.author == "developer1" + assert len(entry.tags) == 2 + + def test_diary_from_dict(self): + """Test creating diary entry from dictionary.""" + data = { + "date": "2024-01-15", + "author": "user1", + "content": "Daily standup notes", + "tags": ["meeting"], + "category": "notes", + } + + entry = DiaryEntry.from_dict(data) + + assert entry.date == "2024-01-15" + assert entry.author == "user1" + assert entry.category == "notes" + + def test_diary_from_dict_defaults(self): + """Test diary entry defaults when creating from incomplete dict.""" + data = {"content": "Some content"} + + entry = DiaryEntry.from_dict(data) + + assert entry.author == "Unknown" + assert entry.category == "general" + assert entry.tags == [] + + +class TestContributorSummary: + """Test ContributorSummary dataclass.""" + + def test_contributor_creation(self): + """Test creating a contributor summary.""" + summary = ContributorSummary(username="developer1") + + assert summary.username == "developer1" + assert len(summary.commits) == 0 + assert summary.total_commits == 0 + + def test_contributor_calculate_stats(self): + """Test calculating contributor statistics.""" + summary = ContributorSummary(username="developer1") + + # Add commits + summary.commits = [ + GitHubCommit( + sha="abc", + message="Commit 1", + author="developer1", + author_email="dev@example.com", + committed_date="2024-01-15T10:00:00Z", + repo="test/repo", + url="https://github.com/test/repo/commit/abc", + additions=100, + deletions=50, + changed_files=5, + ), + GitHubCommit( + sha="def", + message="Commit 2", + author="developer1", + author_email="dev@example.com", + committed_date="2024-01-16T10:00:00Z", + repo="test/repo", + url="https://github.com/test/repo/commit/def", + additions=50, + deletions=20, + changed_files=3, + ), + ] + + # Add PRs + summary.pull_requests = [ + GitHubPullRequest( + number=1, + title="PR 1", + author="developer1", + state="MERGED", + created_at="2024-01-15T10:00:00Z", + merged_at="2024-01-16T10:00:00Z", + closed_at=None, + repo="test/repo", + url="https://github.com/test/repo/pull/1", + additions=200, + deletions=100, + ), + ] + + summary.calculate_stats() + + assert summary.total_commits == 2 + assert summary.total_prs == 1 + assert summary.merged_prs == 1 + assert summary.total_additions == 350 # 100 + 50 + 200 + assert summary.total_deletions == 170 # 50 + 20 + 100 + + +class TestWeeklyReport: + """Test WeeklyReport dataclass.""" + + def test_report_creation(self): + """Test creating a weekly report.""" + report = WeeklyReport( + start_date="2024-01-08", + end_date="2024-01-15", + repos=["intellistream/SAGE"], + ) + + assert report.start_date == "2024-01-08" + assert report.end_date == "2024-01-15" + assert len(report.repos) == 1 + assert report.total_commits == 0 + + def test_report_calculate_overall_stats(self): + """Test calculating overall report statistics.""" + report = WeeklyReport( + start_date="2024-01-08", + end_date="2024-01-15", + repos=["test/repo"], + ) + + # Create contributors + contrib1 = ContributorSummary(username="dev1") + contrib1.total_commits = 10 + contrib1.total_prs = 3 + contrib1.merged_prs = 2 + + contrib2 = ContributorSummary(username="dev2") + contrib2.total_commits = 5 + contrib2.total_prs = 2 + contrib2.merged_prs = 1 + + report.contributors = [contrib1, contrib2] + report.calculate_overall_stats() + + assert report.total_commits == 15 + assert report.total_prs == 5 + assert report.total_merged_prs == 3 + assert report.generated_at != "" diff --git a/apps/tests/work_report_generator/test_operators.py b/apps/tests/work_report_generator/test_operators.py new file mode 100644 index 0000000..0b78a0d --- /dev/null +++ b/apps/tests/work_report_generator/test_operators.py @@ -0,0 +1,638 @@ +"""Unit tests for Work Report Generator operators. + +Tests for sage.apps.work_report_generator.operators module. +""" + +import json +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +from sage.apps.work_report_generator.models import ( + ContributorSummary, + DiaryEntry, + GitHubCommit, + GitHubPullRequest, +) +from sage.apps.work_report_generator.operators import ( + ConsoleSink, + ContributorAggregator, + DiaryEntrySource, + GitHubDataSource, + LLMReportGenerator, + ReportSink, +) + + +class TestGitHubDataSource: + """Test GitHubDataSource operator.""" + + def test_source_creation(self): + """Test creating GitHub data source.""" + source = GitHubDataSource( + repos=["intellistream/SAGE"], + days=7, + ) + + assert source.repos == ["intellistream/SAGE"] + assert source.days == 7 + assert source.current_index == 0 + assert source.fetched is False + + def test_source_creation_with_token(self): + """Test creating source with token.""" + source = GitHubDataSource( + repos=["test/repo"], + days=14, + github_token="test_token", + ) + + assert source.github_token == "test_token" + assert "Bearer test_token" in source.headers["Authorization"] + + def test_source_date_range(self): + """Test source calculates correct date range.""" + source = GitHubDataSource(repos=["test/repo"], days=7) + + expected_start = datetime.now() - timedelta(days=7) + assert abs((source.start_date - expected_start).total_seconds()) < 1 + assert abs((source.end_date - datetime.now()).total_seconds()) < 1 + + def test_source_mock_data(self): + """Test source generates mock data when no token.""" + source = GitHubDataSource(repos=["test/repo"], days=7, github_token="") + + source._use_mock_data() + + assert len(source.commits) > 0 + assert len(source.pull_requests) > 0 + + def test_source_execute_returns_commits_then_prs(self): + """Test source emits commits first, then PRs.""" + source = GitHubDataSource(repos=["test/repo"], days=7, github_token="") + + # Use mock data + source._use_mock_data() + source.fetched = True + + # First items should be commits + result = source.execute() + assert result is not None + assert result["type"] == "commit" + + @patch("sage.apps.work_report_generator.operators.requests.post") + def test_source_graphql_error_handling(self, mock_post): + """Test source handles GraphQL errors gracefully.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"errors": ["Test error"]} + mock_post.return_value = mock_response + + source = GitHubDataSource( + repos=["test/repo"], + days=7, + github_token="test_token", + ) + + result = source._execute_graphql("query { viewer { login } }") + assert "errors" in result + + +class TestDiaryEntrySource: + """Test DiaryEntrySource operator.""" + + def test_source_creation(self): + """Test creating diary entry source.""" + source = DiaryEntrySource(diary_path="/tmp/diaries", days=7) + + assert source.diary_path == Path("/tmp/diaries") + assert source.days == 7 + assert source.fetched is False + + def test_source_no_path(self): + """Test source with no path specified.""" + source = DiaryEntrySource(diary_path=None, days=7) + + assert source.diary_path is None + + def test_source_load_json_entries(self): + """Test loading entries from JSON file.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create test JSON file + diary_file = Path(tmpdir) / "diaries.json" + entries = [ + { + "date": datetime.now().strftime("%Y-%m-%d"), + "author": "testuser", + "content": "Test entry", + "tags": ["test"], + } + ] + diary_file.write_text(json.dumps(entries)) + + source = DiaryEntrySource(diary_path=diary_file, days=7) + source._load_entries() + + assert len(source.entries) == 1 + assert source.entries[0].author == "testuser" + + def test_source_load_markdown_entries(self): + """Test loading entries from Markdown files.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create test markdown file + date_str = datetime.now().strftime("%Y-%m-%d") + md_file = Path(tmpdir) / f"{date_str}.md" + md_file.write_text("# Daily Log\n\nWorked on tests.") + + source = DiaryEntrySource(diary_path=tmpdir, days=7) + source._load_entries() + + assert len(source.entries) == 1 + assert "Daily Log" in source.entries[0].content + + def test_source_date_range_filter(self): + """Test entries are filtered by date range.""" + source = DiaryEntrySource(diary_path=None, days=7) + + # Date within range + recent_date = datetime.now().strftime("%Y-%m-%d") + assert source._is_in_date_range(recent_date) is True + + # Date outside range + old_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + assert source._is_in_date_range(old_date) is False + + def test_source_execute(self): + """Test source execution returns entries one by one.""" + source = DiaryEntrySource(diary_path=None, days=7) + + # Manually add entries + source.entries = [ + DiaryEntry( + date="2024-01-15", + author="user1", + content="Entry 1", + ), + DiaryEntry( + date="2024-01-16", + author="user2", + content="Entry 2", + ), + ] + source.fetched = True + + # Get first entry + result1 = source.execute() + assert result1 is not None + assert result1["type"] == "diary_entry" + assert result1["data"].content == "Entry 1" + + # Get second entry + result2 = source.execute() + assert result2 is not None + assert result2["data"].content == "Entry 2" + + # No more entries + result3 = source.execute() + assert result3 is None + + +class TestContributorAggregator: + """Test ContributorAggregator operator.""" + + def setup_method(self): + """Reset aggregator state before each test.""" + ContributorAggregator.reset() + + def test_aggregator_creation(self): + """Test creating aggregator.""" + aggregator = ContributorAggregator() + assert aggregator is not None + + def test_aggregator_commit(self): + """Test aggregating commits.""" + aggregator = ContributorAggregator() + + commit = GitHubCommit( + sha="abc123", + message="Test commit", + author="developer1", + author_email="dev@example.com", + committed_date="2024-01-15T10:00:00Z", + repo="test/repo", + url="https://github.com/test/repo/commit/abc123", + ) + + result = aggregator.execute( + { + "type": "commit", + "data": commit, + "author": "developer1", + } + ) + + assert result["author"] == "developer1" + assert result["contributor"].total_commits == 1 + + def test_aggregator_pull_request(self): + """Test aggregating pull requests.""" + aggregator = ContributorAggregator() + + pr = GitHubPullRequest( + number=1, + title="Test PR", + author="developer1", + state="MERGED", + created_at="2024-01-15T10:00:00Z", + merged_at="2024-01-16T10:00:00Z", + closed_at=None, + repo="test/repo", + url="https://github.com/test/repo/pull/1", + ) + + result = aggregator.execute( + { + "type": "pull_request", + "data": pr, + "author": "developer1", + } + ) + + assert result["contributor"].total_prs == 1 + assert result["contributor"].merged_prs == 1 + + def test_aggregator_diary_entry(self): + """Test aggregating diary entries.""" + aggregator = ContributorAggregator() + + entry = DiaryEntry( + date="2024-01-15", + author="developer1", + content="Test entry", + ) + + result = aggregator.execute( + { + "type": "diary_entry", + "data": entry, + "author": "developer1", + } + ) + + assert len(result["contributor"].diary_entries) == 1 + + def test_aggregator_multiple_contributors(self): + """Test aggregating data for multiple contributors.""" + aggregator = ContributorAggregator() + + # Add data for dev1 + commit1 = GitHubCommit( + sha="abc", + message="Commit 1", + author="dev1", + author_email="", + committed_date="2024-01-15T10:00:00Z", + repo="test/repo", + url="", + ) + aggregator.execute({"type": "commit", "data": commit1, "author": "dev1"}) + + # Add data for dev2 + commit2 = GitHubCommit( + sha="def", + message="Commit 2", + author="dev2", + author_email="", + committed_date="2024-01-15T11:00:00Z", + repo="test/repo", + url="", + ) + aggregator.execute({"type": "commit", "data": commit2, "author": "dev2"}) + + contributors = ContributorAggregator.get_all_contributors() + assert len(contributors) == 2 + assert "dev1" in contributors + assert "dev2" in contributors + + def test_aggregator_reset(self): + """Test resetting aggregator state.""" + aggregator = ContributorAggregator() + + commit = GitHubCommit( + sha="abc", + message="Test", + author="dev1", + author_email="", + committed_date="", + repo="", + url="", + ) + aggregator.execute({"type": "commit", "data": commit, "author": "dev1"}) + + assert len(ContributorAggregator.get_all_contributors()) > 0 + + ContributorAggregator.reset() + + assert len(ContributorAggregator.get_all_contributors()) == 0 + + def test_aggregator_username_normalization(self): + """Test username normalization for duplicate contributors.""" + ContributorAggregator.reset() + aggregator = ContributorAggregator() + + # Test ShuhaoZhangTony variants + commit1 = GitHubCommit( + sha="abc1", + message="Commit from ShuhaoZhangTony", + author="ShuhaoZhangTony", + author_email="", + committed_date="2024-01-15T10:00:00Z", + repo="test/repo", + url="", + ) + aggregator.execute({"type": "commit", "data": commit1, "author": "ShuhaoZhangTony"}) + + commit2 = GitHubCommit( + sha="abc2", + message="Commit from Shuhao Zhang", + author="Shuhao Zhang", + author_email="", + committed_date="2024-01-15T11:00:00Z", + repo="test/repo", + url="", + ) + result = aggregator.execute({"type": "commit", "data": commit2, "author": "Shuhao Zhang"}) + + # Both should map to ShuhaoZhangTony + assert result["author"] == "ShuhaoZhangTony" + contributors = ContributorAggregator.get_all_contributors() + assert "ShuhaoZhangTony" in contributors + assert "Shuhao Zhang" not in contributors + assert contributors["ShuhaoZhangTony"].total_commits == 2 + + def test_aggregator_copilot_normalization(self): + """Test Copilot username normalization.""" + ContributorAggregator.reset() + aggregator = ContributorAggregator() + + commit1 = GitHubCommit( + sha="cop1", + message="Commit from Copilot", + author="Copilot", + author_email="", + committed_date="2024-01-15T10:00:00Z", + repo="test/repo", + url="", + ) + aggregator.execute({"type": "commit", "data": commit1, "author": "Copilot"}) + + commit2 = GitHubCommit( + sha="cop2", + message="Commit from copilot-swe-agent", + author="copilot-swe-agent", + author_email="", + committed_date="2024-01-15T11:00:00Z", + repo="test/repo", + url="", + ) + result = aggregator.execute( + {"type": "commit", "data": commit2, "author": "copilot-swe-agent"} + ) + + # Both should map to Copilot + assert result["author"] == "Copilot" + contributors = ContributorAggregator.get_all_contributors() + assert "Copilot" in contributors + assert "copilot-swe-agent" not in contributors + assert contributors["Copilot"].total_commits == 2 + + def test_normalize_username_static_method(self): + """Test the normalize_username static method.""" + # ShuhaoZhangTony variants + assert ContributorAggregator.normalize_username("ShuhaoZhangTony") == "ShuhaoZhangTony" + assert ContributorAggregator.normalize_username("Shuhao Zhang") == "ShuhaoZhangTony" + assert ContributorAggregator.normalize_username("shuhao zhang") == "ShuhaoZhangTony" + assert ContributorAggregator.normalize_username("shuhaozhangtony") == "ShuhaoZhangTony" + + # Copilot variants + assert ContributorAggregator.normalize_username("Copilot") == "Copilot" + assert ContributorAggregator.normalize_username("copilot-swe-agent") == "Copilot" + assert ContributorAggregator.normalize_username("github-copilot") == "Copilot" + + # Unknown users should stay unchanged + assert ContributorAggregator.normalize_username("some-random-user") == "some-random-user" + + # Empty string should return Unknown + assert ContributorAggregator.normalize_username("") == "Unknown" + + +class TestLLMReportGenerator: + """Test LLMReportGenerator operator.""" + + def test_generator_creation(self): + """Test creating LLM report generator.""" + generator = LLMReportGenerator(language="zh") + + assert generator.language == "zh" + assert generator._client is None + + def test_generator_simple_summary_zh(self): + """Test generating simple Chinese summary.""" + generator = LLMReportGenerator(language="zh") + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 10 + contributor.total_prs = 3 + contributor.merged_prs = 2 + contributor.total_additions = 500 + contributor.total_deletions = 100 + + summary = generator._generate_simple_summary(contributor) + + assert "testuser" in summary + assert "10" in summary + assert "3" in summary + + def test_generator_simple_summary_en(self): + """Test generating simple English summary.""" + generator = LLMReportGenerator(language="en") + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 5 + contributor.total_prs = 2 + contributor.merged_prs = 1 + contributor.total_additions = 200 + contributor.total_deletions = 50 + + summary = generator._generate_simple_summary(contributor) + + assert "testuser" in summary + assert "contributed" in summary + + def test_generator_execute_non_contributor(self): + """Test generator passes through non-contributor data.""" + generator = LLMReportGenerator(language="zh") + + result = generator.execute({"type": "other", "data": "test"}) + + assert result["type"] == "other" + + def test_generator_execute_contributor(self): + """Test generator processes contributor updates.""" + generator = LLMReportGenerator(language="zh") + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 5 + + result = generator.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + } + ) + + assert "llm_summary" in result + + def test_generator_skips_duplicate_authors(self): + """Test generator only processes each author once.""" + generator = LLMReportGenerator(language="zh") + + contributor = ContributorSummary(username="testuser") + + # First call + result1 = generator.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + } + ) + assert result1 is not None + + # Second call should return None + result2 = generator.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + } + ) + assert result2 is None + + +class TestReportSink: + """Test ReportSink operator.""" + + def test_sink_creation(self): + """Test creating report sink.""" + sink = ReportSink( + output_format="markdown", + repos=["test/repo"], + days=7, + ) + + assert sink.output_format == "markdown" + assert sink.repos == ["test/repo"] + assert sink.days == 7 + + def test_sink_execute_collects_data(self): + """Test sink collects contributor data.""" + sink = ReportSink(output_format="console", repos=["test/repo"], days=7) + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 5 + + sink.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + "llm_summary": "Test summary", + } + ) + + assert "testuser" in sink.contributors + + def test_sink_markdown_output(self): + """Test generating markdown output.""" + with tempfile.NamedTemporaryFile(suffix=".md", delete=False) as f: + output_path = Path(f.name) + + try: + sink = ReportSink( + output_format="markdown", + output_path=output_path, + repos=["test/repo"], + days=7, + ) + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 5 + contributor.calculate_stats() + + sink.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + } + ) + + sink.close() + + content = output_path.read_text() + assert "Weekly Work Report" in content + assert "testuser" in content + finally: + output_path.unlink(missing_ok=True) + + def test_sink_json_output(self): + """Test generating JSON output.""" + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + output_path = Path(f.name) + + try: + sink = ReportSink( + output_format="json", + output_path=output_path, + repos=["test/repo"], + days=7, + ) + + contributor = ContributorSummary(username="testuser") + contributor.total_commits = 5 + contributor.calculate_stats() + + sink.execute( + { + "type": "contributor_update", + "author": "testuser", + "contributor": contributor, + } + ) + + sink.close() + + content = output_path.read_text() + data = json.loads(content) + assert "contributors" in data + assert len(data["contributors"]) == 1 + finally: + output_path.unlink(missing_ok=True) + + +class TestConsoleSink: + """Test ConsoleSink operator.""" + + def test_console_sink_counts(self): + """Test console sink counts processed items.""" + sink = ConsoleSink() + + sink.execute({"type": "commit", "author": "dev1"}) + assert sink.count == 1 + + sink.execute({"type": "pr", "author": "dev2"}) + assert sink.count == 2 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2990b61 --- /dev/null +++ b/build.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# ============================================================================ +# SAGE Examples Build Script +# ============================================================================ +# ็”จไบŽๆž„ๅปบๅ’Œๅ‘ๅธƒ isage-examples ๅ’Œ iapps ๅŒ…ๅˆฐ PyPI +# ============================================================================ + +set -e # ้‡ๅˆฐ้”™่ฏฏ็ซ‹ๅณ้€€ๅ‡บ + +# ้ขœ่‰ฒๅฎšไน‰ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# ่พ…ๅŠฉๅ‡ฝๆ•ฐ +info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" + exit 1 +} + +# ๆฃ€ๆŸฅไพ่ต– +check_dependencies() { + info "ๆฃ€ๆŸฅๆž„ๅปบไพ่ต–..." + + if ! command -v python3 &> /dev/null; then + error "Python 3 ๆœชๅฎ‰่ฃ…" + fi + + # ๆฃ€ๆŸฅๅนถๅฎ‰่ฃ…ๆž„ๅปบๅทฅๅ…ท + if ! python3 -c "import build" &> /dev/null; then + info "ๅฎ‰่ฃ…ๆž„ๅปบๅทฅๅ…ท..." + pip install --upgrade build twine + fi +} + +# ๆธ…็†ๆž„ๅปบ็›ฎๅฝ• +clean() { + info "ๆธ…็†ๆ—ง็š„ๆž„ๅปบๆ–‡ไปถ..." + rm -rf dist/ build/ *.egg-info + rm -rf apps/dist/ apps/build/ apps/*.egg-info + info "ๆธ…็†ๅฎŒๆˆ" +} + +# ๆž„ๅปบ isage-examples +build_examples() { + info "ๆž„ๅปบ isage-examples..." + python3 -m build + info "isage-examples ๆž„ๅปบๅฎŒๆˆ" +} + +# ๆž„ๅปบ iapps +build_apps() { + info "ๆž„ๅปบ iapps..." + cd apps + python3 -m build + cd .. + info "iapps ๆž„ๅปบๅฎŒๆˆ" +} + +# ๆฃ€ๆŸฅๅŒ… +check_packages() { + info "ๆฃ€ๆŸฅๅŒ…ๅฎŒๆ•ดๆ€ง..." + + # ๆฃ€ๆŸฅ isage-examples + if [ -d "dist" ]; then + twine check dist/* + fi + + # ๆฃ€ๆŸฅ iapps + if [ -d "apps/dist" ]; then + twine check apps/dist/* + fi + + info "ๅŒ…ๆฃ€ๆŸฅๅฎŒๆˆ" +} + +# ไธŠไผ ๅˆฐ TestPyPI๏ผˆๆต‹่ฏ•๏ผ‰ +upload_test() { + warn "ไธŠไผ ๅˆฐ TestPyPI๏ผˆๆต‹่ฏ•็Žฏๅขƒ๏ผ‰..." + + # ไธŠไผ  isage-examples + if [ -d "dist" ]; then + info "ไธŠไผ  isage-examples ๅˆฐ TestPyPI..." + twine upload --repository testpypi dist/* + fi + + # ไธŠไผ  iapps + if [ -d "apps/dist" ]; then + info "ไธŠไผ  iapps ๅˆฐ TestPyPI..." + twine upload --repository testpypi apps/dist/* + fi + + info "TestPyPI ไธŠไผ ๅฎŒๆˆ" + info "ๅฎ‰่ฃ…ๆต‹่ฏ•: pip install --index-url https://test.pypi.org/simple/ isage-examples" +} + +# ไธŠไผ ๅˆฐ PyPI๏ผˆๆญฃๅผๅ‘ๅธƒ๏ผ‰ +upload_prod() { + warn "ไธŠไผ ๅˆฐ PyPI๏ผˆๆญฃๅผ็Žฏๅขƒ๏ผ‰..." + read -p "็กฎ่ฎค่ฆๅ‘ๅธƒๅˆฐๆญฃๅผ PyPI๏ผŸ(yes/no): " confirm + + if [ "$confirm" != "yes" ]; then + error "็”จๆˆทๅ–ๆถˆๅ‘ๅธƒ" + fi + + # ไธŠไผ  isage-examples + if [ -d "dist" ]; then + info "ไธŠไผ  isage-examples ๅˆฐ PyPI..." + twine upload dist/* + fi + + # ไธŠไผ  iapps + if [ -d "apps/dist" ]; then + info "ไธŠไผ  iapps ๅˆฐ PyPI..." + twine upload apps/dist/* + fi + + info "PyPI ไธŠไผ ๅฎŒๆˆ" +} + +# ๆ˜พ็คบๅธฎๅŠฉ +show_help() { + cat << EOF +SAGE Examples ๆž„ๅปบ่„šๆœฌ + +็”จๆณ•: + $0 [ๅ‘ฝไปค] + +ๅ‘ฝไปค: + clean - ๆธ…็†ๆž„ๅปบๆ–‡ไปถ + build - ๆž„ๅปบๆ‰€ๆœ‰ๅŒ…๏ผˆexamples + apps๏ผ‰ + examples - ไป…ๆž„ๅปบ isage-examples + apps - ไป…ๆž„ๅปบ iapps + check - ๆฃ€ๆŸฅๅŒ…ๅฎŒๆ•ดๆ€ง + test - ไธŠไผ ๅˆฐ TestPyPI + release - ๅ‘ๅธƒๅˆฐๆญฃๅผ PyPI + all - ๆธ…็† + ๆž„ๅปบ + ๆฃ€ๆŸฅ๏ผˆ้ป˜่ฎค๏ผ‰ + help - ๆ˜พ็คบๆญคๅธฎๅŠฉไฟกๆฏ + +็คบไพ‹: + $0 # ้ป˜่ฎค๏ผšๆธ…็† + ๆž„ๅปบ + ๆฃ€ๆŸฅ + $0 build # ไป…ๆž„ๅปบ + $0 test # ๆž„ๅปบๅนถไธŠไผ ๅˆฐ TestPyPI + $0 release # ๆž„ๅปบๅนถๅ‘ๅธƒๅˆฐ PyPI + +ๆณจๆ„: + - ๅ‘ๅธƒๅ‰่ฏท็กฎไฟๅทฒๆ›ดๆ–ฐ็‰ˆๆœฌๅท + - isage-examples: pyproject.toml ไธญ็š„ version + - iapps: src/sage/apps/_version.py ไธญ็š„ __version__ +EOF +} + +# ไธปๅ‡ฝๆ•ฐ +main() { + case "${1:-all}" in + clean) + clean + ;; + build) + check_dependencies + build_examples + build_apps + ;; + examples) + check_dependencies + build_examples + ;; + apps) + check_dependencies + build_apps + ;; + check) + check_packages + ;; + test) + check_dependencies + clean + build_examples + build_apps + check_packages + upload_test + ;; + release) + check_dependencies + clean + build_examples + build_apps + check_packages + upload_prod + ;; + all) + check_dependencies + clean + build_examples + build_apps + check_packages + info "ๆ‰€ๆœ‰ไปปๅŠกๅฎŒๆˆ๏ผ" + info "่ฟ่กŒ './build.sh test' ไธŠไผ ๅˆฐ TestPyPI" + info "่ฟ่กŒ './build.sh release' ๅ‘ๅธƒๅˆฐๆญฃๅผ PyPI" + ;; + help|--help|-h) + show_help + ;; + *) + error "ๆœช็Ÿฅๅ‘ฝไปค: $1\nไฝฟ็”จ '$0 help' ๆŸฅ็œ‹ๅธฎๅŠฉ" + ;; + esac +} + +# ่ฟ่กŒไธปๅ‡ฝๆ•ฐ +main "$@" diff --git a/docs/adr/0001-d3-executability-regression.md b/docs/adr/0001-d3-executability-regression.md new file mode 100644 index 0000000..fe769c5 --- /dev/null +++ b/docs/adr/0001-d3-executability-regression.md @@ -0,0 +1,38 @@ +# ADR 0001: D3 ็คบไพ‹ๅฏๆ‰ง่กŒๆ€งไธŽไพ่ต–ไธ€่‡ดๆ€งๅ›žๅฝ’ไฟฎๅค + +- Status: Accepted +- Date: 2026-03-03 +- Related issue: https://github.com/intellistream/sage-examples/issues/15 + +## Context + +ๅœจไป“ๅบ“ๆ น็›ฎๅฝ•่ฟ่กŒ `pytest -q apps/tests` ๆ—ถ๏ผŒๆต‹่ฏ•ๆ”ถ้›†้˜ถๆฎตๅ‡บ็Žฐ `ModuleNotFoundError: No module named 'sage.apps'`ใ€‚ + +ๆ ธๅฟƒๅŽŸๅ› ๏ผš + +1. `sage.apps` ๆบ็ ไฝไบŽ `apps/src/sage/apps`๏ผ› +1. ๆ นไป“ๅบ“ pytest ้…็ฝฎๆœชๅฐ† `apps/src` ๆณจๅ…ฅ Python import path๏ผ› +1. ๅœจ `apps/` ๅญ็›ฎๅฝ•ๆ‰ง่กŒ pytest ๆ—ถ๏ผŒๅŒๆ ท็ผบๅคฑ `src` ่ทฏๅพ„ๆณจๅ…ฅใ€‚ + +่ฟ™ไผš้€ ๆˆโ€œไพ่ต–ๅทฒๅฃฐๆ˜Žไฝ†็คบไพ‹ไธๅฏๆ‰ง่กŒโ€็š„ๅ‡ๅคฑ่ดฅ๏ผŒไธ็ฌฆๅˆ D3 ็š„โ€œๅฏๆ‰ง่กŒๆ€งไธŽไพ่ต–ไธ€่‡ดๆ€งๅ›žๅฝ’โ€็›ฎๆ ‡ใ€‚ + +## Decision + +้‡‡็”จๆœ€ๅฐไฟฎๅค๏ผŒไธๅผ•ๅ…ฅๅ…ผๅฎนๅฑ‚๏ผš + +1. ๅœจๆ น็›ฎๅฝ• `pyproject.toml` ็š„ `[tool.pytest.ini_options]` ไธญๆ–ฐๅขž `pythonpath = ["apps/src"]`๏ผ› +1. ๅœจ `apps/pyproject.toml` ็š„ `[tool.pytest.ini_options]` ไธญๆ–ฐๅขž `pythonpath = ["src"]`๏ผ› +1. ไธๆทปๅŠ  shim/re-export/fallback๏ผŒไธไฟฎๆ”นไธšๅŠกๅฏผๅ…ฅ่ทฏๅพ„ใ€‚ + +## Consequences + +- `sage.apps` ๅœจไป“ๅบ“ๅ†…ๆต‹่ฏ•่ฟ่กŒๆ—ถๅฏ่ขซ็จณๅฎšๅฏผๅ…ฅ๏ผ› +- ๅŒๆ—ถๅ…ผๅฎนไธค็งๅธธ็”จๆต‹่ฏ•ๅ…ฅๅฃ๏ผš + - ไป“ๅบ“ๆ น็›ฎๅฝ•๏ผš`pytest apps/tests` + - `apps/` ๅญ็›ฎๅฝ•๏ผš`pytest tests` +- ่ฏฅไฟฎๅคไป…ๅฝฑๅ“ๆต‹่ฏ•ๆ‰ง่กŒ็Žฏๅขƒ๏ผŒไธๆ”นๅ˜่ฟ่กŒๆ—ถๅŒ…็ป“ๆž„ไธŽๅ…ฌๅผ€ APIใ€‚ + +## Verification + +- ๆ‰ง่กŒ๏ผš`pytest -q apps/tests` +- ้ข„ๆœŸ๏ผšๆ”ถ้›†้€š่ฟ‡๏ผŒไธๅ†ๅ‡บ็Žฐ `No module named 'sage.apps'`ใ€‚ diff --git a/docs/adr/0002-d2-cleanup-outdated-examples.md b/docs/adr/0002-d2-cleanup-outdated-examples.md new file mode 100644 index 0000000..d937b26 --- /dev/null +++ b/docs/adr/0002-d2-cleanup-outdated-examples.md @@ -0,0 +1,33 @@ +# ADR 0002: D2 ๆธ…็†่ฟ‡ๆ—ถ็คบไพ‹ไธŽๅ…ผๅฎน่„šๆœฌ + +- Status: Accepted +- Date: 2026-03-03 +- Related issue: https://github.com/intellistream/sage-examples/issues/14 + +## Context + +`examples/` ็›ฎๅฝ•ๅญ˜ๅœจไปฅไธ‹่พน็•Œ้—ฎ้ข˜๏ผš + +1. ๅญ˜ๅœจๆ—ง API ๆผ”็คบ่„šๆœฌ๏ผŒไธๅฑžไบŽๅฝ“ๅ‰็ปดๆŠค่Œƒๅ›ด๏ผ› +1. `examples/README.md` ๆ–‡ๆกฃๅ†…ๅฎนๆŸๅไธ”ๅŒ…ๅซๆ— ๆ•ˆๅ…ฅๅฃ๏ผˆๅฆ‚ `examples/apps/...`๏ผ‰๏ผ› +1. ้กถๅฑ‚ README ไปๆšด้œฒ่ฟ‡ๆ—ถๅ…ฅๅฃ๏ผŒ้€ ๆˆโ€œๆ–‡ๆกฃๅฏ่งไฝ†ๅฎž้™…ไธ็ปดๆŠคโ€็š„่ฏฏๅฏผใ€‚ + +## Decision + +ๆ‰ง่กŒๆœ€ๅฐ่พน็•Œๆ”ถๆ•›๏ผš + +1. ๅˆ ้™ค่ฟ‡ๆ—ถ่„šๆœฌ `examples/run_runtime_api_layering.py`๏ผ› +1. ้‡ๅ†™ `examples/README.md`๏ผŒไป…ไฟ็•™ๅฏ่ฟ่กŒไธ”ๅ—็ปดๆŠค็š„ๅ…ฅๅฃ๏ผ› +1. ๅŒๆญฅๆ›ดๆ–ฐๆ น `README.md` ็š„็คบไพ‹ๅˆ—่กจ๏ผŒ็งป้™ค่ฟ‡ๆ—ถๅ…ฅๅฃ๏ผ› +1. ไธๆ–ฐๅขž shim/re-export/fallback๏ผŒไธไฟ็•™ๅ…ผๅฎนๅ…ฅๅฃใ€‚ + +## Consequences + +- `examples/` ๅชไฟ็•™ๅฝ“ๅ‰็ปดๆŠค็š„ๅฏ่ฟ่กŒๅ…ฅๅฃ๏ผ› +- ๆ–‡ๆกฃไธŽๅฎž้™…่„šๆœฌ้›†ๅˆไธ€่‡ด๏ผŒ้™ไฝŽ่ฏฏๅฏผไธŽ็ปดๆŠคๆˆๆœฌ๏ผ› +- ๅฏนๅค– API ๆ— ๆ–ฐๅขžๅ…ผๅฎนๅฑ‚๏ผŒ็ฌฆๅˆ Wave D D2 ็บฆๆŸใ€‚ + +## Verification + +- ๆ ก้ชŒๅˆ ้™คๅŽๅ…ฅๅฃ่„šๆœฌ้›†ๅˆ๏ผš`examples/run_*.py` +- ๆ‰ง่กŒๅŸบ็ก€ๅ›žๅฝ’๏ผš`pytest -q examples/test_apps.py apps/tests/test_package.py` diff --git a/docs/adr/0003-d1-runnable-examples-boundary.md b/docs/adr/0003-d1-runnable-examples-boundary.md new file mode 100644 index 0000000..23f28a0 --- /dev/null +++ b/docs/adr/0003-d1-runnable-examples-boundary.md @@ -0,0 +1,32 @@ +# ADR 0003: D1 ่พน็•Œๆ”ถๆ•›๏ผˆๅชไฟ็•™ๅฏ่ฟ่กŒๆ ทไพ‹๏ผ‰ + +- Status: Accepted +- Date: 2026-03-03 +- Related issue: https://github.com/intellistream/sage-examples/issues/13 + +## Context + +D1 ็›ฎๆ ‡่ฆๆฑ‚็คบไพ‹ไป“ๅบ“่š็„ฆโ€œๅฏ่ฟ่กŒๆ ทไพ‹โ€๏ผŒ้ฟๅ…ๆŠŠๆ ธๅฟƒๅฎž็Žฐ้€ป่พ‘ๅธฆๅ…ฅ `examples/` ๅ…ฅๅฃๅฑ‚ใ€‚ + +ๅœจๅฎž่ทตไธญ๏ผŒ่ฟ™็ฑป่พน็•Œๅฎนๆ˜“ๅ›žๅฝ’๏ผš + +1. ๅ…ฅๅฃ่„šๆœฌๅฏ่ƒฝ็›ดๆŽฅๅฏผๅ…ฅ `sage.kernel/platform/middleware` ็ญ‰ๅบ•ๅฑ‚ๆจกๅ—๏ผ› +1. ๅทฒๅˆ ้™ค็š„่ฟ‡ๆ—ถๅ…ฅๅฃๅฏ่ƒฝ่ขซ่ฏฏๅŠ ๅ›žไป“ๅบ“ใ€‚ + +## Decision + +ๆ–ฐๅขžๅ›žๅฝ’้—จ็ฆๆต‹่ฏ•๏ผˆ`examples/test_apps.py`๏ผ‰๏ผš + +1. `test_examples_boundary_no_core_layer_imports`๏ผš้™ๆ€ๆ‰ซๆ `examples/run_*.py`๏ผŒ็ฆๆญข็›ดๆŽฅๅฏผๅ…ฅๆ ธๅฟƒๅฎž็Žฐๅฑ‚ๅŒ…๏ผ› +1. `test_deprecated_entries_removed`๏ผš้”ๅฎšๅทฒ็งป้™คๅ…ฅๅฃ `run_runtime_api_layering.py`๏ผŒ้˜ฒๆญขๅ›žๅฝ’ใ€‚ + +## Consequences + +- `examples/` ๅ…ฅๅฃๅฑ‚่Œ่ดฃ่ขซ่‡ชๅŠจๅŒ–็บฆๆŸไธบโ€œๅบ”็”จๆผ”็คบๅ…ฅๅฃโ€๏ผ› +- ๆ ธๅฟƒๅฎž็Žฐ้€ป่พ‘ไฟๆŒๅœจ `apps/src/sage/apps/`๏ผ› +- ไธๆ–ฐๅขžไปปไฝ•ๅ…ผๅฎนๅฑ‚๏ผˆshim/re-export/fallback๏ผ‰ใ€‚ + +## Verification + +- `pytest -q examples/test_apps.py apps/tests/test_package.py` +- `ruff check .` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0c9a786 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# SAGE Application Examples + +This directory contains maintained entry scripts for the `sage.apps` package. + +## Available Scripts + +- `run_video_intelligence.py` โ€” Video analysis pipeline. +- `run_medical_diagnosis.py` โ€” Medical diagnosis workflow. +- `run_article_monitoring.py` โ€” Article monitoring pipeline. +- `run_auto_scaling_chat.py` โ€” Auto-scaling chat demo. +- `run_smart_home.py` โ€” Smart home automation demo. +- `run_literature_report_assistant.py` โ€” Personalized literature recommendation demo. +- `run_work_report.py` โ€” Work report generation demo. +- `demo_feature_extraction.py` โ€” Feature extraction demo for medical imaging. + +## Run Examples + +From repository root: + +```bash +python examples/run_video_intelligence.py +python examples/run_medical_diagnosis.py +python examples/run_article_monitoring.py +python examples/run_auto_scaling_chat.py +python examples/run_smart_home.py +python examples/run_literature_report_assistant.py +python examples/run_work_report.py +python examples/demo_feature_extraction.py +``` + +## Notes + +- This directory only keeps runnable and maintained scripts. +- Deprecated/legacy API demos are removed from this directory. +- Application implementations live in `apps/src/sage/apps/`. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..7049b2d --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,7 @@ +""" +SAGE Applications Examples + +This package contains example scripts for running applications from sage-apps. +""" + +__version__ = "0.1.0" diff --git a/examples/demo_feature_extraction.py b/examples/demo_feature_extraction.py new file mode 100644 index 0000000..04bf44e --- /dev/null +++ b/examples/demo_feature_extraction.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Demo: Feature Extraction with Pretrained Models + +This script demonstrates the use of CLIP and DINOv2 models for feature extraction +in the medical diagnosis application. + +Issue #894: Use pretrained models for feature extraction instead of random features +""" + +import numpy as np + +from sage.apps.medical_diagnosis.agents.image_analyzer import ( + TORCH_AVAILABLE, + ImageAnalyzer, +) + + +def demo_clip_features(): + """Demonstrate CLIP feature extraction""" + print("\n" + "=" * 80) + print("๐Ÿ”ฌ Demo: CLIP Feature Extraction") + print("=" * 80) + + config = { + "models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}, + "image_processing": {"feature_extraction": {"method": "clip", "dimension": 512}}, + } + + if not TORCH_AVAILABLE: + print("โš ๏ธ PyTorch not available, will use mock features") + else: + print("โœ… PyTorch available, attempting to load CLIP model...") + + analyzer = ImageAnalyzer(config) + + # Create a mock medical image + mock_mri_image = np.random.randint(0, 255, (512, 512), dtype=np.uint8) + print("\n๐Ÿ“ธ Created mock MRI image (512x512)") + + # Extract features + print("\n๐Ÿ” Extracting features...") + features = analyzer._extract_features(mock_mri_image) + + if features is not None: + print("\nโœ… Feature extraction successful!") + print(f" - Feature dimension: {features.shape[0]}") + print(f" - Feature type: {features.dtype}") + print(f" - Feature norm: {np.linalg.norm(features):.4f}") + print(f" - Feature range: [{features.min():.4f}, {features.max():.4f}]") + + # Show a few feature values + print("\n๐Ÿ“Š First 10 feature values:") + print(f" {features[:10]}") + else: + print("\nโŒ Feature extraction failed") + + +def demo_dinov2_features(): + """Demonstrate DINOv2 feature extraction""" + print("\n" + "=" * 80) + print("๐Ÿ”ฌ Demo: DINOv2 Feature Extraction") + print("=" * 80) + + config = { + "models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}, + "image_processing": {"feature_extraction": {"method": "dinov2", "dimension": 768}}, + } + + if not TORCH_AVAILABLE: + print("โš ๏ธ PyTorch not available, will use mock features") + else: + print("โœ… PyTorch available, attempting to load DINOv2 model...") + + analyzer = ImageAnalyzer(config) + + # Create a mock medical image + mock_mri_image = np.random.randint(0, 255, (512, 512), dtype=np.uint8) + print("\n๐Ÿ“ธ Created mock MRI image (512x512)") + + # Extract features + print("\n๐Ÿ” Extracting features...") + features = analyzer._extract_features(mock_mri_image) + + if features is not None: + print("\nโœ… Feature extraction successful!") + print(f" - Feature dimension: {features.shape[0]}") + print(f" - Feature type: {features.dtype}") + print(f" - Feature norm: {np.linalg.norm(features):.4f}") + print(f" - Feature range: [{features.min():.4f}, {features.max():.4f}]") + + # Show a few feature values + print("\n๐Ÿ“Š First 10 feature values:") + print(f" {features[:10]}") + else: + print("\nโŒ Feature extraction failed") + + +def demo_full_analysis(): + """Demonstrate full image analysis pipeline""" + print("\n" + "=" * 80) + print("๐Ÿ”ฌ Demo: Full Image Analysis Pipeline") + print("=" * 80) + + config = { + "models": {"vision_model": "Qwen/Qwen2-VL-7B-Instruct"}, + "image_processing": {"feature_extraction": {"method": "clip", "dimension": 768}}, + } + + analyzer = ImageAnalyzer(config) + + # Analyze a non-existent image (will use mock data) + print("\n๐Ÿ” Running full analysis (using mock data)...") + result = analyzer.analyze("test_patient_mri.dcm") + + print("\n๐Ÿ“Š Analysis Results:") + print(f" - Image quality: {result['image_quality']:.2f}") + print(f" - Vertebrae detected: {len(result['vertebrae'])}") + print(f" - Discs detected: {len(result['discs'])}") + print(f" - Abnormalities found: {len(result['abnormalities'])}") + + if result["image_embedding"] is not None: + print("\n๐Ÿงฌ Image Embedding:") + print(f" - Dimension: {result['image_embedding'].shape[0]}") + print(f" - Norm: {np.linalg.norm(result['image_embedding']):.4f}") + + print("\n๐Ÿ“ Detected Abnormalities:") + for i, abnormality in enumerate(result["abnormalities"], 1): + print(f" {i}. {abnormality['description']}") + print(f" Location: {abnormality['location']}, Severity: {abnormality['severity']}") + + +def main(): + """Main demo function""" + print("\n" + "=" * 80) + print("๐ŸŽฏ Medical Image Feature Extraction Demo") + print("=" * 80) + print("\nThis demo showcases the integration of pretrained models (CLIP, DINOv2)") + print("for feature extraction in medical image analysis.") + print("\nIssue #894: https://github.com/intellistream/SAGE/issues/894") + + # Demo 1: CLIP + demo_clip_features() + + # Demo 2: DINOv2 + demo_dinov2_features() + + # Demo 3: Full analysis + demo_full_analysis() + + print("\n" + "=" * 80) + print("โœ… Demo Complete!") + print("=" * 80) + print("\n๐Ÿ’ก Note: When models are not available (e.g., no PyTorch or GPU),") + print(" the system automatically falls back to mock features for testing.") + print("\n") + + +if __name__ == "__main__": + main() diff --git a/examples/run_article_monitoring.py b/examples/run_article_monitoring.py new file mode 100644 index 0000000..7a5c4db --- /dev/null +++ b/examples/run_article_monitoring.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Article Monitoring System Example + +This script demonstrates the Article Monitoring application from sage-apps. +It monitors arXiv for relevant research papers using keyword and semantic filtering. + +Requirements: + pip install -e sage-apps + # Or: pip install isage-apps + +Usage: + python apps/run_article_monitoring.py + python apps/run_article_monitoring.py --keywords "transformer,attention" + python apps/run_article_monitoring.py --category cs.CL --max-articles 20 + +Test Configuration: + @test_category: apps + @test_speed: medium + @test_requires: [network] # May need network for arXiv API +""" + +import argparse +import sys + +try: + from sage.apps.article_monitoring import run_article_monitoring_pipeline + from sage.foundation import CustomLogger +except ImportError as e: + print(f"Error importing sage.apps.article_monitoring: {e}") + print("\nPlease install sage-apps:") + print(" cd sage-apps && pip install -e .") + print(" Or: pip install isage-apps") + sys.exit(1) + + +def main(): + """Run the article monitoring pipeline.""" + parser = argparse.ArgumentParser( + description="SAGE Article Monitoring System - Monitor and filter arXiv papers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Use default settings + python %(prog)s + + # Custom keywords + python %(prog)s --keywords "transformer,attention,bert,nlp" + + # Different category + python %(prog)s --category cs.CL --max-articles 20 + + # Verbose mode + python %(prog)s --verbose + +Features: + - Real-time arXiv paper fetching + - Keyword-based filtering + - Semantic similarity filtering + - Ranked recommendations + """, + ) + + parser.add_argument( + "--keywords", + "-k", + type=str, + help="Comma-separated list of keywords", + ) + + parser.add_argument( + "--topics", + "-t", + type=str, + help="Comma-separated list of interest topics", + ) + + parser.add_argument( + "--category", + "-c", + type=str, + default="cs.AI", + help="arXiv category to monitor (default: cs.AI)", + ) + + parser.add_argument( + "--max-articles", + "-m", + type=int, + default=10, + help="Maximum number of articles to fetch (default: 10)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Parse keywords + keywords = None + if args.keywords: + keywords = [kw.strip() for kw in args.keywords.split(",")] + + # Parse topics + topics = None + if args.topics: + topics = [t.strip() for t in args.topics.split(",")] + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + print("=" * 70) + print("SAGE Article Monitoring System - Example") + print("=" * 70) + print() + + # Run pipeline + try: + run_article_monitoring_pipeline( + keywords=keywords, + interest_topics=topics, + category=args.category, + max_articles=args.max_articles, + verbose=args.verbose, + ) + except Exception as e: + print(f"\nError running article monitoring pipeline: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/run_auto_scaling_chat.py b/examples/run_auto_scaling_chat.py new file mode 100644 index 0000000..c1af6ec --- /dev/null +++ b/examples/run_auto_scaling_chat.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Auto-Scaling Chat System Example + +This script demonstrates the Auto-Scaling Chat application from sage-apps. +It simulates elastic scaling of chat servers based on user load. + +Requirements: + pip install -e sage-apps + # Or: pip install isage-apps + +Usage: + python apps/run_auto_scaling_chat.py + python apps/run_auto_scaling_chat.py --duration 60 --peak-rate 80 + python apps/run_auto_scaling_chat.py --verbose + +Test Configuration: + @test_category: apps + @test_speed: medium +""" + +import argparse +import sys + +try: + from sage.apps.auto_scaling_chat import run_auto_scaling_demo + from sage.foundation import CustomLogger +except ImportError as e: + print(f"Error importing sage.apps.auto_scaling_chat: {e}") + print("\nPlease install sage-apps:") + print(" cd sage-apps && pip install -e .") + print(" Or: pip install isage-apps") + sys.exit(1) + + +def main(): + """Run the auto-scaling chat system demo.""" + parser = argparse.ArgumentParser( + description="SAGE Auto-Scaling Chat System - Elastic resource management demo", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run default simulation (30s) + python %(prog)s + + # Short simulation with high peak + python %(prog)s --duration 20 --peak-rate 80 + + # Long simulation with moderate load + python %(prog)s --duration 60 --peak-rate 40 + + # Verbose mode with scaling events + python %(prog)s --verbose + +Features: + - Simulated variable user load + - Automatic server scaling (up/down) + - Load balancing across servers + - Real-time metrics collection + """, + ) + + parser.add_argument( + "--duration", + "-d", + type=int, + default=30, + help="Simulation duration in seconds (default: 30)", + ) + + parser.add_argument( + "--base-rate", + "-b", + type=int, + default=5, + help="Base user load (default: 5)", + ) + + parser.add_argument( + "--peak-rate", + "-p", + type=int, + default=50, + help="Peak user load (default: 50)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + print("=" * 70) + print("SAGE Auto-Scaling Chat System - Example") + print("=" * 70) + print() + + # Run demo + try: + run_auto_scaling_demo( + duration=args.duration, + base_rate=args.base_rate, + peak_rate=args.peak_rate, + verbose=args.verbose, + ) + except Exception as e: + print(f"\nError running auto-scaling demo: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/run_literature_report_assistant.py b/examples/run_literature_report_assistant.py new file mode 100644 index 0000000..aff48d3 --- /dev/null +++ b/examples/run_literature_report_assistant.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Run a lightweight literature report assistant demo.""" + +from __future__ import annotations + +from sage.apps.literature_report_assistant import ( + ResearchProfile, + generate_personalized_recommendation_report, + preprocess_paper_stream, + recommend_papers, + search_arxiv_papers, +) + + +def main() -> None: + topic = "RAG agentic retrieval" + profile = ResearchProfile( + interests=["็ง‘็ ”ๆŽจ่", "ๆตๅผๆ–‡็Œฎๅค„็†"], + keywords=["rag", "retrieval", "agentic", "stream"], + ) + print(f"๐Ÿ”Ž ๆฃ€็ดขไธป้ข˜: {topic}") + + papers = search_arxiv_papers(topic, max_results=12) + cleaned = preprocess_paper_stream(papers) + recommendations = recommend_papers(cleaned, profile=profile, top_k=5) + report = generate_personalized_recommendation_report(topic, recommendations) + + print("\n" + "=" * 80) + print(report) + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/examples/run_medical_diagnosis.py b/examples/run_medical_diagnosis.py new file mode 100644 index 0000000..2313c6e --- /dev/null +++ b/examples/run_medical_diagnosis.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Medical Diagnosis System Example + +This script demonstrates how to use the Medical Diagnosis application from sage-apps. +It provides AI-assisted medical imaging analysis using a multi-agent system. + +Requirements: + pip install -e sage-apps[medical] + # Or: pip install isage-apps[medical] + +Usage: + python apps/run_medical_diagnosis.py + python apps/run_medical_diagnosis.py --case-id case_0001 + python apps/run_medical_diagnosis.py --interactive + +Test Configuration: + @test_category: apps + @test_speed: slow + @test_requires: [medical, data] + @test_skip_ci: true + @test:skip - Requires medical dataset to be downloaded +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +try: + from sage.apps.medical_diagnosis.run_diagnosis import main as diagnosis_main +except ImportError as e: + print(f"Error importing sage.apps.medical_diagnosis: {e}") + print("\nPlease install sage-apps with medical dependencies:") + print(" cd sage-apps && pip install -e .[medical]") + print(" Or: pip install isage-apps[medical]") + sys.exit(1) + + +def check_and_setup_data(data_dir: str, auto_setup: bool = False) -> bool: + """ๆฃ€ๆŸฅๆ•ฐๆฎๆ˜ฏๅฆๅญ˜ๅœจ๏ผŒๅฆ‚ๆžœไธๅญ˜ๅœจๅˆ™ๆ็คบ็”จๆˆท่‡ชๅŠจ่ฎพ็ฝฎ""" + data_path = Path(data_dir) + processed_dir = data_path / "processed" + + # ๆฃ€ๆŸฅๆ•ฐๆฎๆ˜ฏๅฆๅญ˜ๅœจ + if processed_dir.exists() and (processed_dir / "train_index.json").exists(): + return True + + print(f"\n{'=' * 60}") + print("ๆ•ฐๆฎ้›†ๆœชๆ‰พๅˆฐ") + print("=" * 60) + print(f"ๆœŸๆœ›็š„ๆ•ฐๆฎ็›ฎๅฝ•: {data_dir}") + print("") + + # ๆŸฅๆ‰พ setup_data.sh ่„šๆœฌ + setup_script = data_path.parent / "setup_data.sh" + + if not setup_script.exists(): + # ๅฐ่ฏ• sage-apps ๅŒ…ๅ†…็š„่„šๆœฌไฝ็ฝฎ + sage_apps_root = Path(__file__).parent.parent / "sage-apps" + setup_script = sage_apps_root / "src/sage/apps/medical_diagnosis/setup_data.sh" + + if not setup_script.exists(): + print("โŒ ๆ•ฐๆฎ่ฎพ็ฝฎ่„šๆœฌๆœชๆ‰พๅˆฐ") + print("") + print("ๆœŸๆœ›็ป“ๆž„:") + print(" {data_dir}/processed/images/") + print(" {data_dir}/processed/train_index.json") + print(" {data_dir}/processed/test_index.json") + return False + + print(f"ๆ‰พๅˆฐๆ•ฐๆฎ่ฎพ็ฝฎ่„šๆœฌ: {setup_script}") + print("") + print("๐Ÿค– ่‡ชๅŠจไธ‹่ฝฝๅนถๅ‡†ๅค‡ๆ•ฐๆฎ้›†...") + print("ๆ็คบ: ๅฆ‚ๆžœไธๆƒณ่‡ชๅŠจไธ‹่ฝฝ๏ผŒ่ฏทไฝฟ็”จ Ctrl+C ๅ–ๆถˆ") + print("") + print("ๅผ€ๅง‹่‡ชๅŠจ่ฎพ็ฝฎๆ•ฐๆฎ้›†...") + print("=" * 60) + + try: + # ่ฟ่กŒ setup_data.sh + subprocess.run( + ["bash", str(setup_script)], + cwd=str(setup_script.parent), + check=True, + text=True, + ) + + print("=" * 60) + print("โœ… ๆ•ฐๆฎ้›†่ฎพ็ฝฎๅฎŒๆˆ๏ผ") + print("") + return True + + except subprocess.CalledProcessError: + print("=" * 60) + print("โŒ ๆ•ฐๆฎ้›†่ฎพ็ฝฎๅคฑ่ดฅ") + print("") + print("ๆ‚จๅฏไปฅๆ‰‹ๅŠจ่ฟ่กŒไปฅไธ‹ๅ‘ฝไปคๆฅ่ฎพ็ฝฎๆ•ฐๆฎ:") + print(f" bash {setup_script}") + print("") + return False + except Exception as e: + print(f"โŒ ๅ‘็”Ÿ้”™่ฏฏ: {e}") + return False + + +def main(): + """Run the medical diagnosis system.""" + parser = argparse.ArgumentParser( + description="SAGE Medical Diagnosis System - AI-assisted medical imaging analysis", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run with default demo case + python %(prog)s + + # Analyze a specific case + python %(prog)s --case-id case_0001 + + # Interactive mode + python %(prog)s --interactive + + # Use custom data directory + python %(prog)s --data-dir path/to/medical/data + +Features: + - Multi-agent diagnostic workflow + - Medical image analysis + - Knowledge base integration + - Diagnostic report generation + - Interactive consultation mode + """, + ) + + parser.add_argument("--case-id", type=str, help="Specific case ID to analyze (e.g., case_0001)") + + parser.add_argument( + "--data-dir", + type=str, + default="packages/sage-apps/src/sage/apps/medical_diagnosis/data", + help="Path to medical diagnosis data directory", + ) + + parser.add_argument( + "--interactive", + action="store_true", + help="Run in interactive consultation mode", + ) + + parser.add_argument("--output", type=str, help="Output directory for diagnostic reports") + + parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") + + parser.add_argument( + "--auto-setup", + action="store_true", + help="Automatically download and setup data without prompting", + ) + + args = parser.parse_args() + + # Check and setup data if needed + if not check_and_setup_data(args.data_dir, auto_setup=args.auto_setup): + print("\nโš ๏ธ ่ญฆๅ‘Š: ๆ•ฐๆฎ้›†ๆœชๅฐฑ็ปช") + print("็ณป็ปŸๅฐ†ๅฐ่ฏ•ไฝฟ็”จๆจกๆ‹Ÿๆ•ฐๆฎ่ฟ่กŒ...") + print("") + + print("=" * 60) + print("SAGE Medical Diagnosis System") + print("=" * 60) + print(f"Data Directory: {args.data_dir}") + if args.case_id: + print(f"Case ID: {args.case_id}") + if args.interactive: + print("Mode: Interactive") + else: + print("Mode: Automated Analysis") + if args.output: + print(f"Output: {args.output}") + print("=" * 60) + print() + + # Call the medical diagnosis main function + try: + diagnosis_main() + except Exception as e: + print(f"Error running medical diagnosis system: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/run_smart_home.py b/examples/run_smart_home.py new file mode 100644 index 0000000..11dac53 --- /dev/null +++ b/examples/run_smart_home.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Smart Home System Example + +This script demonstrates the Smart Home application from sage-apps. +It simulates IoT device coordination for automated laundry workflow. + +Requirements: + pip install -e sage-apps + # Or: pip install isage-apps + +Usage: + python apps/run_smart_home.py + python apps/run_smart_home.py --cycles 3 + python apps/run_smart_home.py --verbose + +Test Configuration: + @test_category: apps + @test_speed: fast +""" + +import argparse +import sys + +try: + from sage.apps.smart_home import run_smart_home_demo + from sage.foundation import CustomLogger +except ImportError as e: + print(f"Error importing sage.apps.smart_home: {e}") + print("\nPlease install sage-apps:") + print(" cd sage-apps && pip install -e .") + print(" Or: pip install isage-apps") + sys.exit(1) + + +def main(): + """Run the smart home automation demo.""" + parser = argparse.ArgumentParser( + description="SAGE Smart Home System - IoT device automation demo", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run single laundry cycle + python %(prog)s + + # Run multiple cycles + python %(prog)s --cycles 3 + + # Verbose mode with event logging + python %(prog)s --cycles 2 --verbose + +Features: + - IoT device simulation (robot, washer, dryer, sensors) + - Automated workflow coordination + - Environmental monitoring + - Event-driven architecture + """, + ) + + parser.add_argument( + "--cycles", + "-c", + type=int, + default=1, + help="Number of laundry cycles to run (default: 1)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + # Disable global console debug unless verbose + if not args.verbose: + CustomLogger.disable_global_console_debug() + + print("=" * 70) + print("SAGE Smart Home System - Example") + print("=" * 70) + print() + + # Run demo + try: + run_smart_home_demo(num_cycles=args.cycles, verbose=args.verbose) + except Exception as e: + print(f"\nError running smart home demo: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/run_video_intelligence.py b/examples/run_video_intelligence.py new file mode 100644 index 0000000..3d253a0 --- /dev/null +++ b/examples/run_video_intelligence.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Video Intelligence Pipeline Example + +This script demonstrates how to use the Video Intelligence application from sage-apps. +It performs multi-model video analysis using CLIP and MobileNetV3. + +Requirements: + pip install -e sage-apps[video] + # Or: pip install isage-apps[video] + +Usage: + python apps/run_video_intelligence.py --video path/to/video.mp4 + python apps/run_video_intelligence.py --video path/to/video.mp4 --config custom_config.yaml + +Test Configuration: + @test_category: apps + @test_speed: slow + @test_requires: [video] + @test_skip_ci: true + @test:skip - Requires video file parameter +""" + +import argparse +import sys +from pathlib import Path + +try: + from sage.apps.video.video_intelligence_pipeline import main as video_main +except ImportError as e: + print(f"Error importing sage.apps.video: {e}") + print("\nPlease install sage-apps with video dependencies:") + print(" cd sage-apps && pip install -e .[video]") + print(" Or: pip install isage-apps[video]") + sys.exit(1) + + +def main(): + """Run the video intelligence pipeline.""" + parser = argparse.ArgumentParser( + description="SAGE Video Intelligence Pipeline - Multi-model video analysis", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Analyze a video file + python %(prog)s --video path/to/video.mp4 + + # Use custom configuration + python %(prog)s --video path/to/video.mp4 --config config_video_intelligence.yaml + + # Process multiple frames + python %(prog)s --video path/to/video.mp4 --max-frames 100 + +Features: + - Scene understanding with CLIP model + - Action recognition with MobileNetV3 + - Frame-by-frame analysis + - Comprehensive video insights + - Configurable processing pipeline + """, + ) + + parser.add_argument("--video", type=str, help="Path to the video file to analyze") + + parser.add_argument("--config", type=str, default=None, help="Path to configuration file") + + parser.add_argument("--max-frames", type=int, help="Maximum number of frames to process") + + parser.add_argument("--output", type=str, help="Output directory for results") + + args = parser.parse_args() + + # If no video is provided, show help and exit successfully (for testing purposes) + if not args.video: + print("=" * 60) + print("SAGE Video Intelligence Pipeline") + print("=" * 60) + print("No video file provided. This example requires a video file to process.") + print("\nUsage:") + print(" python examples/apps/run_video_intelligence.py --video path/to/video.mp4") + print("\nFor more information, use --help") + print("=" * 60) + sys.exit(0) + + # Validate video file if provided + if args.video and not Path(args.video).exists(): + print(f"Error: Video file not found: {args.video}") + sys.exit(1) + + # Validate config file if provided + if args.config and not Path(args.config).exists(): + print(f"Error: Config file not found: {args.config}") + sys.exit(1) + + print("=" * 60) + print("SAGE Video Intelligence Pipeline") + print("=" * 60) + if args.video: + print(f"Video: {args.video}") + if args.config: + print(f"Config: {args.config}") + else: + print("Config: Using default configuration") + if args.max_frames: + print(f"Max Frames: {args.max_frames}") + if args.output: + print(f"Output: {args.output}") + print("=" * 60) + print() + + # Call the video intelligence main function + # The actual implementation will handle the arguments + try: + video_main() + except Exception as e: + print(f"Error running video intelligence pipeline: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/run_work_report.py b/examples/run_work_report.py new file mode 100644 index 0000000..40eed70 --- /dev/null +++ b/examples/run_work_report.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Work Report Generator Example + +This script demonstrates the Work Report Generator application from sage-apps. +It generates weekly/daily work reports by combining GitHub data with optional diary entries. + +Requirements: + pip install -e sage-apps + # Or: pip install isage-apps + +Usage: + python apps/run_work_report.py + python apps/run_work_report.py --days 7 + python apps/run_work_report.py --format json --output report.json + python apps/run_work_report.py --no-submodules --branch main + +Test Configuration: + @test_category: apps + @test_speed: medium + @test_requires: [network] # Needs GitHub API access +""" + +import argparse +import os +import sys + +try: + from sage.apps.work_report_generator import run_work_report_pipeline + from sage.apps.work_report_generator.operators import GitHubDataSource + from sage.foundation import CustomLogger +except ImportError as e: + print(f"Error importing sage.apps.work_report_generator: {e}") + print("\nPlease install sage-apps:") + print(" cd sage-apps && pip install -e .") + print(" Or: pip install isage-apps") + sys.exit(1) + + +def main(): + """Run the work report generator pipeline.""" + parser = argparse.ArgumentParser( + description="SAGE Work Report Generator - Generate weekly/daily work reports", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Use default settings (all SAGE repos, main-dev branch, 7 days) + python %(prog)s + + # Main repo only, without submodules + python %(prog)s --no-submodules + + # Specify custom repositories + python %(prog)s --repos intellistream/SAGE,intellistream/sageLLM + + # Use different branch (e.g., main instead of main-dev) + python %(prog)s --branch main + + # Custom time range + python %(prog)s --days 14 + + # JSON output + python %(prog)s --format json --output reports/weekly.json + + # English report with verbose output + python %(prog)s --language en --verbose + + # Without LLM summarization (faster) + python %(prog)s --no-llm + +Environment Variables: + GITHUB_TOKEN or GIT_TOKEN: GitHub personal access token + +Features: + - Fetches commits and PRs from GitHub repositories + - Supports fetching from specific branches (default: main-dev) + - Includes all SAGE submodules by default + - Aggregates contributions by contributor + - Generates AI-powered summaries (optional) + - Supports markdown, JSON, and console output + """, + ) + + parser.add_argument( + "--repos", + "-r", + type=str, + default=None, + help="Comma-separated list of repositories. If not specified, fetches all SAGE repos.", + ) + + parser.add_argument( + "--branch", + "-b", + type=str, + default="main-dev", + help="Branch name to fetch commits from (default: main-dev)", + ) + + parser.add_argument( + "--days", + "-d", + type=int, + default=7, + help="Number of days to look back (default: 7)", + ) + + parser.add_argument( + "--format", + "-f", + type=str, + choices=["console", "markdown", "json"], + default="markdown", + help="Output format (default: markdown)", + ) + + parser.add_argument( + "--output", + "-o", + type=str, + help="Output file path (default: .sage/reports/weekly_report_.)", + ) + + parser.add_argument( + "--diary-path", + type=str, + help="Path to diary directory or file for personal notes", + ) + + parser.add_argument( + "--language", + "-l", + type=str, + choices=["zh", "en"], + default="zh", + help="Report language (default: zh for Chinese)", + ) + + parser.add_argument( + "--token", + "-t", + type=str, + help="GitHub personal access token (can also use GITHUB_TOKEN env var)", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose logging", + ) + + parser.add_argument( + "--no-llm", + action="store_true", + help="Disable LLM summary generation for faster reports", + ) + + parser.add_argument( + "--no-submodules", + action="store_true", + help="Only fetch from main SAGE repo, skip submodules", + ) + + args = parser.parse_args() + + # Setup logging + logger = CustomLogger.get_logger(__name__) + + # Parse repositories + repos = None + if args.repos: + repos = [r.strip() for r in args.repos.split(",")] + + # Get GitHub token + github_token = args.token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GIT_TOKEN") + + if not github_token: + logger.warning( + "No GitHub token provided. Set GITHUB_TOKEN environment variable " + "or use --token option for full functionality. Using mock data." + ) + + # Determine repos to display + display_repos = ( + repos + if repos + else (GitHubDataSource.SAGE_REPOS if not args.no_submodules else ["intellistream/SAGE"]) + ) + + # Print configuration + print() + print("Configuration:") + print(f" Target Branch: {args.branch}") + print(f" Repositories ({len(display_repos)}):") + for repo in display_repos: + print(f" - {repo}") + print(f" Time Range: {args.days} days") + print(f" Output Format: {args.format}") + print(f" Language: {'Chinese' if args.language == 'zh' else 'English'}") + print(f" LLM Summary: {'Disabled' if args.no_llm else 'Enabled'}") + print(f" GitHub Token: {'Provided' if github_token else 'Not provided (mock data)'}") + print() + + # Run pipeline + try: + result = run_work_report_pipeline( + repos=repos, + branch=args.branch, + days=args.days, + output_format=args.format, + output_path=args.output, + diary_path=args.diary_path, + language=args.language, + github_token=github_token, + verbose=args.verbose, + use_llm=not args.no_llm, + include_submodules=not args.no_submodules, + ) + + if result: + print(f"\n Report saved to: {result}") + print("\nTo view the report:") + print(f" cat {result}") + + except KeyboardInterrupt: + print("\n\n Pipeline interrupted by user.") + sys.exit(1) + except Exception as e: + logger.error(f"Pipeline failed: {e}") + if args.verbose: + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/test_apps.py b/examples/test_apps.py new file mode 100644 index 0000000..3c619c3 --- /dev/null +++ b/examples/test_apps.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +ๆต‹่ฏ• sage-examples/apps ็›ฎๅฝ•ไธญๆ‰€ๆœ‰ๅบ”็”จ็š„ๅฏผๅ…ฅๅ’ŒๅŸบๆœฌๅŠŸ่ƒฝ +""" + +import ast +import sys +from pathlib import Path + +# ๆทปๅŠ ้กน็›ฎๆ น็›ฎๅฝ•ๅˆฐ่ทฏๅพ„ +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + + +def test_imports(): + """ๆต‹่ฏ•ๅบ”็”จๅฏผๅ…ฅ""" + print("=" * 60) + print("ๆต‹่ฏ•ๅบ”็”จๅฏผๅ…ฅ") + print("=" * 60) + + tests_passed = 0 + tests_failed = 0 + + apps_to_test = [ + ("run_article_monitoring.py", ["yaml", "requests"]), + ("run_auto_scaling_chat.py", ["yaml"]), + ("run_medical_diagnosis.py", ["yaml", "PIL", "numpy"]), + ("run_smart_home.py", ["yaml"]), + ("run_video_intelligence.py", ["cv2", "torch", "torchvision", "PIL"]), + ("run_work_report.py", ["yaml"]), + ] + + for app_file, required_modules in apps_to_test: + print(f"\nๆต‹่ฏ•: {app_file}") + print(f" ๆฃ€ๆŸฅไพ่ต–: {', '.join(required_modules)}") + + all_imported = True + for module in required_modules: + try: + __import__(module) + print(f" โœ“ {module}") + except ImportError as e: + print(f" โœ— {module}: {e}") + all_imported = False + + if all_imported: + print(f" โœ… {app_file} - ๆ‰€ๆœ‰ไพ่ต–ๅทฒๅฎ‰่ฃ…") + tests_passed += 1 + else: + print(f" โš ๏ธ {app_file} - ้ƒจๅˆ†ไพ่ต–็ผบๅคฑ") + tests_failed += 1 + + print("\n" + "=" * 60) + print(f"ๆต‹่ฏ•็ป“ๆžœ: {tests_passed} ้€š่ฟ‡, {tests_failed} ๅคฑ่ดฅ") + print("=" * 60) + + # Use assert instead of return for pytest + assert tests_failed == 0, f"{tests_failed} ไธชๅบ”็”จ็š„ไพ่ต–็ผบๅคฑ" + + +def test_script_help(): + """ๆต‹่ฏ•่„šๆœฌ --help ๅ‚ๆ•ฐ""" + print("\n" + "=" * 60) + print("ๆต‹่ฏ•่„šๆœฌ --help ๅ‚ๆ•ฐ") + print("=" * 60) + + apps_dir = Path(__file__).parent + test_scripts = [] + + for script in apps_dir.glob("run_*.py"): + print(f"\nๆต‹่ฏ•: {script.name}") + # ่ฟ™้‡Œๅชๆฃ€ๆŸฅๆ–‡ไปถๅญ˜ๅœจ๏ผŒไธๅฎž้™…่ฟ่กŒ๏ผŒๅ› ไธบๅฏ่ƒฝ้œ€่ฆ้…็ฝฎ + if script.exists(): + print(" โœ“ ๆ–‡ไปถๅญ˜ๅœจ") + test_scripts.append(script.name) + else: + print(" โœ— ๆ–‡ไปถไธๅญ˜ๅœจ") + + print(f"\nๆ‰พๅˆฐ {len(test_scripts)} ไธชๅบ”็”จ่„šๆœฌ") + # Use assert instead of return for pytest + assert len(test_scripts) > 0, "ๆฒกๆœ‰ๆ‰พๅˆฐไปปไฝ•ๅบ”็”จ่„šๆœฌ" + + +def test_examples_boundary_no_core_layer_imports(): + """็คบไพ‹ๅ…ฅๅฃไธๅบ”็›ดๆŽฅไพ่ต–ๆ ธๅฟƒๅฎž็Žฐๅฑ‚ใ€‚""" + apps_dir = Path(__file__).parent + retired_prefixes = ("kernel", "platform", "middleware", "libs") + forbidden_prefixes = ( + *(f"sage.{name}" for name in retired_prefixes), + ) + + violations: list[str] = [] + + for script in apps_dir.glob("run_*.py"): + tree = ast.parse(script.read_text(encoding="utf-8"), filename=str(script)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith(forbidden_prefixes): + violations.append(f"{script.name}: import {alias.name}") + elif isinstance(node, ast.ImportFrom): + module_name = node.module or "" + if module_name.startswith(forbidden_prefixes): + violations.append(f"{script.name}: from {module_name} import ...") + + assert not violations, "\n".join(violations) + + +def test_deprecated_entries_removed(): + """่ฟ‡ๆ—ถๅ…ฅๅฃ่„šๆœฌๅบ”ไฟๆŒ็งป้™ค็Šถๆ€ใ€‚""" + apps_dir = Path(__file__).parent + deprecated_entries = [ + "run_runtime_api_layering.py", + ] + + existing = [name for name in deprecated_entries if (apps_dir / name).exists()] + assert not existing, f"ๅ‘็Žฐๅทฒ็งป้™ค็š„่ฟ‡ๆ—ถๅ…ฅๅฃๅ†ๆฌกๅ‡บ็Žฐ: {existing}" + + +def main(): + """่ฟ่กŒๆ‰€ๆœ‰ๆต‹่ฏ•""" + print("SAGE Examples Apps ๆต‹่ฏ•") + print("ๆต‹่ฏ•็Žฏๅขƒ: Python", sys.version.split()[0]) + print() + + results = [] + + # ๆต‹่ฏ•ๅฏผๅ…ฅ + results.append(("ๅฏผๅ…ฅๆต‹่ฏ•", test_imports())) + + # ๆต‹่ฏ•่„šๆœฌ + results.append(("่„šๆœฌๆฃ€ๆŸฅ", test_script_help())) + + # ๆ€ป็ป“ + print("\n" + "=" * 60) + print("ๆต‹่ฏ•ๆ€ป็ป“") + print("=" * 60) + for name, passed in results: + status = "โœ… ้€š่ฟ‡" if passed else "โŒ ๅคฑ่ดฅ" + print(f"{name}: {status}") + + all_passed = all(r[1] for r in results) + if all_passed: + print("\nโœ… ๆ‰€ๆœ‰ๆต‹่ฏ•้€š่ฟ‡๏ผ") + return 0 + else: + print("\nโš ๏ธ ้ƒจๅˆ†ๆต‹่ฏ•ๅคฑ่ดฅ๏ผŒ่ฏทๆฃ€ๆŸฅไพ่ต–ใ€‚") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/post-commit b/hooks/post-commit new file mode 100755 index 0000000..cdd868c --- /dev/null +++ b/hooks/post-commit @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Post-commit hook โ€” auto-bump BUILD version digit after each commit +# +# Version format: X.Y.Z โ†’ X.Y.Z.1 (first build counter) +# X.Y.Z.N โ†’ X.Y.Z.N+1 +# +# Single source of truth: src//_version.py (or root if no src/) +# This hook amends the commit in-place; pre-push hook never bumps version. + +set -eo pipefail + +# Recursion guard +if [ -f ".git/SAGE_POST_COMMIT_RUNNING" ]; then + exit 0 +fi + +# Kill-switch +if [ "${SAGE_SKIP_VERSION_BUMP:-0}" = "1" ]; then + exit 0 +fi + +# Colors +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Bump BUILD digit: X.Y.Z.N โ†’ X.Y.Z.(N+1), X.Y.Z โ†’ X.Y.Z.1 +bump_version() { + local v="$1" + if [[ "$v" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}.$((BASH_REMATCH[4] + 1))" + return 0 + fi + if [[ "$v" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}.1" + return 0 + fi + return 1 +} + +# Find _version.py โ€” search src/ first (standard layout), then root (non-src repos) +find_version_file() { + local found + found=$(find src -maxdepth 4 -name '_version.py' \ + -not -path '*/.git/*' \ + -not -path '*/dist/*' \ + -not -path '*/.egg-info/*' \ + -not -path '*/build/*' \ + 2>/dev/null | head -1) + if [ -n "$found" ]; then + echo "$found" + return 0 + fi + # Fallback: root-level search (for repos without src/ layout) + find . -maxdepth 3 -name '_version.py' \ + -not -path '*/.git/*' \ + -not -path '*/dist/*' \ + -not -path '*/.egg-info/*' \ + -not -path '*/build/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/_cmake_test_compile/*' \ + 2>/dev/null | head -1 +} + +VERSION_FILE=$(find_version_file) +if [ -z "$VERSION_FILE" ]; then + exit 0 # No _version.py found โ€” not a tracked Python package +fi + +# If developer manually touched _version.py in this commit, skip auto-bump +if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '_version.py'; then + exit 0 +fi + +current_version=$(grep -oP '__version__ = "\K[^"]+' "$VERSION_FILE" 2>/dev/null || true) +if [ -z "$current_version" ]; then + exit 0 +fi + +if ! new_version=$(bump_version "$current_version"); then + echo -e "${YELLOW}โš ๏ธ Could not auto-bump version (invalid format: $current_version)${NC}" + exit 0 +fi + +echo -e "${BLUE}๐Ÿ“ฆ Auto-bumping version: $current_version โ†’ $new_version${NC}" + +# Lock to prevent recursion when we amend +touch .git/SAGE_POST_COMMIT_RUNNING + +# Update _version.py (single source of truth) +sed -i "s/__version__ = \"${current_version}\"/__version__ = \"${new_version}\"/" "$VERSION_FILE" +git add "$VERSION_FILE" + +# If this repo has a lock_deps.sh script, sync dependency bounds too (sagellm meta) +if [ -f "scripts/lock_deps.sh" ]; then + echo -e "${BLUE}๐Ÿ”’ Syncing dep bounds + constraints.txt...${NC}" + bash scripts/lock_deps.sh --update-bounds > /dev/null 2>&1 || { + echo -e "${YELLOW}โš ๏ธ lock_deps.sh --update-bounds failed (non-fatal)${NC}" + } + git add constraints.txt src/sagellm/constraints.txt 2>/dev/null || true +fi + +# Amend commit with the version bump (no new prompt) +git commit --amend --no-edit --no-verify + +rm -f .git/SAGE_POST_COMMIT_RUNNING + +echo -e "${GREEN}โœ“ Version bumped to $new_version (commit amended)${NC}" +exit 0 diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..766e957 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Pre-commit hook โ€” code quality checks before commit +# +# Checks: +# 1. Trailing whitespace +# 2. Merge conflict markers +# 3. Large files (>5 MB) +# 4. Auto-fix + lint staged Python files with ruff (gracefully skipped if absent) +# 5. Hardcoded API keys in staged Python files +# 6. Debug statements (pdb/breakpoint) in staged Python files + +set -eo pipefail + +# Colors +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +echo -e "${CYAN}๐Ÿ” Running pre-commit checks...${NC}" + +staged_files=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) +if [ -z "$staged_files" ]; then + echo -e "${GREEN}โœ“ No staged changes${NC}" + exit 0 +fi + +# --- Trailing whitespace --- +if ! git diff --cached --check --diff-filter=ACM 2>/dev/null; then + echo -e "${RED}โœ— Trailing whitespace found. Fix before committing.${NC}" + exit 1 +fi +echo -e "${GREEN}โœ“ No trailing whitespace${NC}" + +# --- Merge conflict markers --- +if git diff --cached 2>/dev/null | grep -qE '^\+(<<<<<<<|=======|>>>>>>>)'; then + echo -e "${RED}โœ— Merge conflict markers found in staged changes${NC}" + exit 1 +fi +echo -e "${GREEN}โœ“ No conflict markers${NC}" + +# --- Large files (>5 MB) --- +max_size=5242880 +large="" +while IFS= read -r f; do + [ -f "$f" ] || continue + sz=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo 0) + [ "$sz" -gt "$max_size" ] && large="${large} $f ($((sz / 1024 / 1024))MB)\n" +done <<< "$staged_files" +if [ -n "$large" ]; then + echo -e "${RED}โœ— Large files (>5MB) staged:${NC}" + printf "%b" "$large" + exit 1 +fi +echo -e "${GREEN}โœ“ No large files${NC}" + +# --- Ruff: auto-fix + lint staged Python files --- +STAGED_PY=$(echo "$staged_files" | grep '\.py$' || true) +if [ -n "$STAGED_PY" ]; then + if command -v ruff &>/dev/null; then + echo -e "${YELLOW}๐Ÿ“ Auto-fixing staged Python files...${NC}" + echo "$STAGED_PY" | xargs ruff format 2>/dev/null || true + echo "$STAGED_PY" | xargs ruff check --fix 2>/dev/null || true + # Re-stage auto-fixed files + echo "$STAGED_PY" | xargs git add + echo "๐Ÿ” Final lint check on entire repo..." + if ! ruff check .; then + echo -e "${RED}โœ— ruff check failed. Run 'ruff check --fix .' to fix.${NC}" + exit 1 + fi + echo -e "${GREEN}โœ“ Lint check passed${NC}" + else + echo -e "${YELLOW}โš ๏ธ ruff not found, skipping lint${NC}" + fi +fi + +# --- Hardcoded API keys --- +if [ -n "$STAGED_PY" ] && echo "$STAGED_PY" | xargs grep -l "api[_-]key\s*=\s*['\"]sk-" 2>/dev/null | grep -q .; then + echo -e "${RED}โœ— Hardcoded API keys detected in staged Python files!${NC}" + exit 1 +fi + +# --- Debug statements --- +if [ -n "$STAGED_PY" ] && echo "$STAGED_PY" | xargs grep -l "import pdb\|breakpoint()" 2>/dev/null | grep -q .; then + echo -e "${YELLOW}โš ๏ธ Debug statements found (pdb/breakpoint). Use --no-verify to skip.${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ“ All pre-commit checks passed!${NC}" +exit 0 diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..9d11661 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,303 @@ +#!/bin/bash +# Pre-push hook โ€” version check + post-push PyPI publish +# +# Flow: +# 1. Block direct push to main branch +# 2. Only proceed for main-dev pushes (other branches: skip cleanly) +# 3. Auto-bump version if unchanged in recent commits +# 4. Exit 0 โ†’ git push proceeds immediately +# 5. Background job publishes to PyPI after push finishes (if token present) +# +# Version bumping: auto-increments last segment if version unchanged. +# This hook may create one version-bump commit before the push. + +# Recursion guard +if [ "${_SAGE_PP_RUNNING:-0}" = "1" ]; then exit 0; fi + +# Publish mode: "public" โ†’ publish openly; "private" โ†’ internal only +PUBLISH_MODE=public + +# Colors +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +DIM='\033[2m' +NC='\033[0m' + +WANT_PUBLISH=false +REPO_DIR="$(pwd)" +REPO_NAME="$(basename "$REPO_DIR")" +PUBLISH_LOG="/tmp/${REPO_NAME}-publish-$$.log" +PUSHING_MAIN_DEV=false +PUSH_LOCAL_SHA="" + +# --- Block direct push to main; detect main-dev push --- +while read -r local_ref local_sha remote_ref remote_sha; do + if [ "$local_ref" = "refs/heads/main" ] || [ "$remote_ref" = "refs/heads/main" ]; then + echo -e "${RED}โœ— Direct push to main is forbidden${NC}" + echo -e "${YELLOW} Please push to main-dev first, then merge via PR.${NC}" + exit 1 + fi + if [ "$local_ref" = "refs/heads/main-dev" ] || [ "$remote_ref" = "refs/heads/main-dev" ]; then + PUSHING_MAIN_DEV=true + PUSH_LOCAL_SHA="$local_sha" + fi +done + +# Version check + publish only applies to main-dev pushes +if [ "$PUSHING_MAIN_DEV" != true ]; then + exit 0 +fi + +# Safe read: falls back to default if /dev/tty is unavailable (SSH, IDE, etc.) +safe_read() { + local varname="$1" + local default="$2" + local input + if [ -t 0 ] || [ -c /dev/tty ] 2>/dev/null; then + if IFS= read -r input /dev/null; then + printf -v "$varname" "%s" "$input" + else + printf -v "$varname" "%s" "$default" + fi + else + printf -v "$varname" "%s" "$default" + fi +} + +# Find all _version.py files in repo +find_version_files() { + find . -maxdepth 4 -name '_version.py' \ + -not -path '*/node_modules/*' \ + -not -path '*/.git/*' \ + -not -path '*/dist/*' \ + -not -path '*/.egg-info/*' \ + -not -path '*/build/*' \ + 2>/dev/null +} + +# Update version in pyproject.toml (static) and/or _version.py (dynamic) +update_version() { + local old_version="$1" + local new_version="$2" + local updated=false + + if grep -q '^version = "' pyproject.toml 2>/dev/null; then + sed -i "s/version = \"${old_version}\"/version = \"${new_version}\"/" pyproject.toml + git add pyproject.toml + updated=true + fi + + while IFS= read -r VERSION_FILE; do + if [ -f "$VERSION_FILE" ] && grep -q '__version__ = "' "$VERSION_FILE" 2>/dev/null; then + sed -i "s/__version__ = \"${old_version}\"/__version__ = \"${new_version}\"/" "$VERSION_FILE" + git add "$VERSION_FILE" + updated=true + fi + done < <(find_version_files) + + if [ "$updated" = false ]; then + echo -e "${RED}โœ— Failed to update version (no version file found)${NC}" + return 1 + fi + + return 0 +} + +# Auto-increment the last version component by 1 (X.Y.Z โ†’ X.Y.Z+1, X.Y.Z.N โ†’ X.Y.Z.N+1) +bump_patch() { + local v="$1" + local IFS='.' + read -ra parts <<< "$v" + local last_idx=$(( ${#parts[@]} - 1 )) + parts[$last_idx]=$(( parts[$last_idx] + 1 )) + echo "${parts[*]}" +} + +# Check if PyPI token is available (no interaction needed) +has_pypi_token() { + if [ -n "${TWINE_PASSWORD:-}" ] || [ -n "${TWINE_TOKEN:-}" ] || [ -n "${UV_PUBLISH_TOKEN:-}" ]; then + return 0 + fi + + if [ -f "$HOME/.pypirc" ]; then + if awk ''' + BEGIN { in_pypi = 0; found = 0 } + /^[[:space:]]*\[pypi\][[:space:]]*$/ { in_pypi = 1; next } + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { in_pypi = 0 } + in_pypi && /^[[:space:]]*(password|token)[[:space:]]*=[[:space:]]*.+$/ { found = 1; exit } + END { exit(found ? 0 : 1) } + ''' "$HOME/.pypirc" 2>/dev/null; then + return 0 + fi + fi + + return 1 +} + +# Resolve publisher CLI command (binary first, then python -m fallback) +resolve_publisher_cmd() { + if command -v sage-pypi-publisher &> /dev/null; then + PUBLISH_CMD=(sage-pypi-publisher) + return 0 + fi + + if command -v python3 &> /dev/null; then + if python3 - <<'PY_HOOK' >/dev/null 2>&1 +import importlib.util +import sys +sys.exit(0 if importlib.util.find_spec("pypi_publisher") else 1) +PY_HOOK + then + PUBLISH_CMD=(python3 -m pypi_publisher.cli) + return 0 + fi + fi + + return 1 +} + +# Schedule PyPI publish as background job after push completes +schedule_publish() { + local version="$1" + local package="$2" + + if ! resolve_publisher_cmd; then + echo -e "${YELLOW}โš  sage-pypi-publisher not found, skipping auto-publish${NC}" + echo -e "${DIM} Install: python -m pip install isage-pypi-publisher${NC}" + return + fi + + echo -e "${GREEN}๐Ÿ“ฆ PyPI publish scheduled (runs after push)${NC}" + echo -e "${DIM} Log: tail -f ${PUBLISH_LOG}${NC}" + + ( + GIT_PID="$PPID" + while kill -0 "$GIT_PID" 2>/dev/null; do + sleep 1 + done + sleep 1 + + cd "$REPO_DIR" || exit 1 + + # Verify the push actually landed before publishing + remote_sha="$(git ls-remote origin "refs/heads/main-dev" | awk '{print $1}')" + if [ -n "$PUSH_LOCAL_SHA" ] && [ -n "$remote_sha" ] && [ "$remote_sha" != "$PUSH_LOCAL_SHA" ]; then + { + echo "โญ Skip publish: push SHA mismatch" + echo " expected=${PUSH_LOCAL_SHA}" + echo " remote=${remote_sha}" + } >> "$PUBLISH_LOG" 2>&1 + exit 0 + fi + + { + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "๐Ÿ“ฆ Post-push: Building ${package} ${version}..." + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + + rm -rf dist/ build/ *.egg-info 2>/dev/null || true + + if "${PUBLISH_CMD[@]}" build . --upload --no-dry-run --mode "${PUBLISH_MODE}"; then + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "โœ“ Successfully uploaded ${package} ${version} to PyPI" + echo "๐Ÿ”— https://pypi.org/project/${package}/${version}/" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + else + _pub_exit=$? + echo "" + echo "โœ— Failed to upload to PyPI (exit code: ${_pub_exit})" + echo " Re-run: sage-pypi-publisher build . --upload --no-dry-run --mode ${PUBLISH_MODE}" + fi + } >> "$PUBLISH_LOG" 2>&1 + + if grep -q "Successfully uploaded" "$PUBLISH_LOG" 2>/dev/null; then + echo -e "\n${GREEN}โœ“ PyPI: ${package} ${version} published${NC}" + else + echo -e "\n${RED}โœ— PyPI publish failed. See: ${PUBLISH_LOG}${NC}" + fi + ) & + disown +} + +# --- Main Logic --- + +if [ ! -f pyproject.toml ]; then + exit 0 +fi + +PACKAGE_NAME=$(grep -oP '^name = "\K[^"]+' pyproject.toml 2>/dev/null || echo "unknown") + +# Get version: prefer _version.py (dynamic), fallback to pyproject.toml (static) +CURRENT_VERSION="" +while IFS= read -r VERSION_FILE; do + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(grep -oP '__version__ = "\K[^"]+' "$VERSION_FILE" 2>/dev/null || true) + [ -n "$CURRENT_VERSION" ] && break + fi +done < <(find_version_files) + +if [ -z "$CURRENT_VERSION" ]; then + CURRENT_VERSION=$(grep -oP '^version = "\K[^"]+' pyproject.toml 2>/dev/null || true) +fi + +if [ -z "$CURRENT_VERSION" ] || [ "$CURRENT_VERSION" = "unknown" ]; then + exit 0 +fi + +# --- Version / Publish --- +# post-commit already bumps the BUILD digit on every commit, so by the time +# we push the version is always current. The only thing pre-push needs to +# guard against is re-pushing a version that was already published to PyPI +# (e.g. after a failed push/publish on a previous attempt). + +# Quick PyPI check (5s timeout, non-blocking) +# Exit codes: 0=version exists on PyPI, 1=not found, 2=network/other error +PYPI_CHECK_RESULT=1 +if [ "$PACKAGE_NAME" != "unknown" ] && command -v python3 &>/dev/null; then + python3 - "$PACKAGE_NAME" "$CURRENT_VERSION" <<'PY' && PYPI_CHECK_RESULT=0 || PYPI_CHECK_RESULT=$? +import json, sys, urllib.request +try: + with urllib.request.urlopen(f"https://pypi.org/pypi/{sys.argv[1]}/json", timeout=5) as r: + sys.exit(0 if sys.argv[2] in json.load(r).get("releases", {}) else 1) +except urllib.error.HTTPError as e: + sys.exit(1 if e.code == 404 else 2) +except (urllib.error.URLError, OSError, TimeoutError): + sys.exit(2) +except Exception: + sys.exit(2) +PY +fi + +if [ "$PYPI_CHECK_RESULT" -eq 0 ]; then + new_version=$(bump_patch "$CURRENT_VERSION") + old_version="$CURRENT_VERSION" + echo -e "${YELLOW}โš  ${PACKAGE_NAME} ${CURRENT_VERSION} already on PyPI โ€” auto-bumping: ${old_version} โ†’ ${new_version}${NC}" + if update_version "$CURRENT_VERSION" "$new_version"; then + git commit -m "chore: bump version to ${new_version}" + CURRENT_VERSION="$new_version" + echo -e "${GREEN}โœ“ Bumped to ${new_version}${NC}" + else + exit 1 + fi +elif [ "$PYPI_CHECK_RESULT" -eq 2 ]; then + echo -e "${DIM}(PyPI check skipped โ€” network/timeout)${NC}" +fi + +echo -e "${GREEN}โœ“ [${REPO_NAME}] Version: ${CURRENT_VERSION}${NC}" + +if has_pypi_token; then + echo -e "${BLUE}๐Ÿ“ฆ Auto-publishing ${CURRENT_VERSION} to PyPI (token found)...${NC}" + WANT_PUBLISH=true +else + echo -e "${DIM} (no PyPI token โ€” skipping publish. Add token to ~/.pypirc or set TWINE_PASSWORD)${NC}" +fi + +if [ "$WANT_PUBLISH" = true ]; then + schedule_publish "$CURRENT_VERSION" "$PACKAGE_NAME" +fi + +# Exit 0 โ†’ push proceeds immediately, never blocked by build/upload +exit 0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7002b4c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,190 @@ +# ============================================================================ +# SAGE Examples Package Configuration +# ============================================================================ +# Production application examples for SAGE framework +# ============================================================================ + +# ---------------------------------------------------------------------------- +# ๆž„ๅปบ็ณป็ปŸ้…็ฝฎ +# ---------------------------------------------------------------------------- +[build-system] +requires = [ + "setuptools>=65.0", # ็Žฐไปฃ setuptools + "wheel", # wheel ๆž„ๅปบๆ”ฏๆŒ +] +build-backend = "setuptools.build_meta" + +# ---------------------------------------------------------------------------- +# ้กน็›ฎๅ…ƒๆ•ฐๆฎ +# ---------------------------------------------------------------------------- +[project] +name = "isage-examples" +version = "0.1.1.19" +description = "SAGE Examples - Production application examples for SAGE framework" +readme = "README.md" +requires-python = ">=3.10" + +# ่ฎธๅฏ่ฏ +license = "MIT" + +# ้กน็›ฎไฝœ่€… +authors = [{ name = "IntelliStream Team", email = "shuhao_zhang@hust.edu.cn" }] + +# PyPI ๆœ็ดขๅ…ณ้”ฎ่ฏ +keywords = [ + "sage", + "examples", + "applications", + "ai", + "llm", + "rag", + "multimodal", + "video-intelligence", + "medical-diagnosis", + "computer-vision", + "intellistream", +] + +# PyPI ๅˆ†็ฑปๅ™จ +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Education", +] + +# ---------------------------------------------------------------------------- +# ๆ ธๅฟƒไพ่ต– - ่ฟ่กŒๅบ”็”จ็คบไพ‹ๆ‰€้œ€็š„ไพ่ต–๏ผˆ้ตๅพช SAGE ๅŽŸๅˆ™๏ผš้ป˜่ฎคๅฎ‰่ฃ…๏ผ‰ +# ---------------------------------------------------------------------------- +dependencies = [ + # ๅ•ไธ€ SAGE ไธปไป“ๆ ธๅฟƒๅŒ… + "isage>=0.3.0", + + # ๅŸบ็ก€ๅทฅๅ…ท + "pyyaml>=6.0", # ้…็ฝฎๆ–‡ไปถ่งฃๆž + "numpy>=1.26.0,<2.3.0", # ๆ•ฐ็ป„ๆ“ไฝœ + + # ๅบ”็”จ็คบไพ‹ไพ่ต–๏ผˆๆ‰€ๆœ‰็คบไพ‹่ฟ่กŒๆ‰€้œ€๏ผ‰ + "pillow>=10.0.0", # ๅ›พๅƒๅค„็† + "opencv-python-headless>=4.5.0", # ่ง†้ข‘ๅค„็†๏ผˆๆ— GUIไพ่ต–๏ผ‰ + "torch>=2.7.0,<3.0.0", # PyTorch ๆทฑๅบฆๅญฆไน ๆก†ๆžถ + "torchvision>=0.22.0,<1.0.0", # ่ง†่ง‰ๆจกๅž‹ + "transformers>=4.52.0", # CLIP ๅ’Œ transformers + "scikit-learn>=1.3.0", # ๆœบๅ™จๅญฆไน ๅทฅๅ…ท + "beautifulsoup4>=4.9.0", # HTML ่งฃๆž + "requests>=2.25.0", # HTTP ่ฏทๆฑ‚ +] + +# ---------------------------------------------------------------------------- +# ๅฏ้€‰ไพ่ต–็ป„ - ไป…ๅผ€ๅ‘ๆจกๅผ๏ผˆ้ตๅพช SAGE ๅŽŸๅˆ™๏ผšๅชๆœ‰ dev๏ผ‰ +# ---------------------------------------------------------------------------- +[project.optional-dependencies] + +full = [] + +# ๅผ€ๅ‘ๅ’Œๆต‹่ฏ•ไพ่ต– +dev = [ + "isage-examples[full]", + "pytest>=7.0.0", # ๆต‹่ฏ•ๆก†ๆžถ + "pytest-cov>=4.0.0", # ่ฆ†็›–็އๆŠฅๅ‘Š + "ruff==0.14.6", # ไปฃ็ ๆฃ€ๆŸฅๅ’Œๆ ผๅผๅŒ–๏ผˆไธŽ SAGE ไธปไป“ๅบ“็‰ˆๆœฌไธ€่‡ด๏ผ‰ + "mypy>=1.0.0", # ้™ๆ€็ฑปๅž‹ๆฃ€ๆŸฅ + "black>=23.0.0", # ไปฃ็ ๆ ผๅผๅŒ– +] + +# ---------------------------------------------------------------------------- +# ้กน็›ฎ้“พๆŽฅ +# ---------------------------------------------------------------------------- +[project.urls] +Homepage = "https://github.com/intellistream/SAGE" +Documentation = "https://intellistream.github.io/sage-docs" +Repository = "https://github.com/intellistream/sage-examples" +Issues = "https://github.com/intellistream/SAGE/issues" + +# ---------------------------------------------------------------------------- +# ๅŒ…ๆž„ๅปบ้…็ฝฎ +# ---------------------------------------------------------------------------- +[tool.setuptools.packages.find] +where = ["."] # ๆบ็ ๆœ็ดขๆ น็›ฎๅฝ• +include = [ + "examples*", # ๅบ”็”จ็คบไพ‹ +] +exclude = [ + "tests*", # ๆŽ’้™คๆต‹่ฏ• + "apps*", # apps ็‹ฌ็ซ‹ๆ‰“ๅŒ… + "*.egg-info*", # ๆŽ’้™คๆž„ๅปบไบง็‰ฉ + "__pycache__*", # ๆŽ’้™ค็ผ“ๅญ˜ +] + +[tool.setuptools] +zip-safe = false + +# ๅŒ…ๅซๆ•ฐๆฎๆ–‡ไปถ (็ฎ€ๅŒ–้…็ฝฎ๏ผŒ็งป้™คๆณจ้‡Š้ฟๅ… bytecode compiler ่งฃๆž้”™่ฏฏ) +[tool.setuptools.package-data] +"*" = ["*.yaml", "*.yml", "*.json", "*.txt", "*.md", "data/**/*", "config/**/*"] + +# ---------------------------------------------------------------------------- +# ๅผ€ๅ‘ๅทฅๅ…ท้…็ฝฎ +# ---------------------------------------------------------------------------- + +# ไปฃ็ ๆ ผๅผๅŒ– (Black) +[tool.black] +line-length = 100 # ๆœ€ๅคง่กŒ้•ฟๅบฆ +target-version = ['py310', 'py311'] # ็›ฎๆ ‡ Python ็‰ˆๆœฌ + +# ไปฃ็ ๆฃ€ๆŸฅๅ’Œๆ ผๅผๅŒ– (Ruff) +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] # ่กŒ้•ฟๅบฆ็”ฑ formatter ๅค„็† + +[tool.ruff.lint.isort] +known-first-party = ["sage"] + +# ้™ๆ€็ฑปๅž‹ๆฃ€ๆŸฅ (MyPy) +[tool.mypy] +python_version = "3.10" # Python ็‰ˆๆœฌ +warn_return_any = false # ็คบไพ‹ไปฃ็ ไธๅผบๅˆถ่ฆๆฑ‚ +warn_unused_configs = true # ่ญฆๅ‘Šๆœชไฝฟ็”จ็š„้…็ฝฎ +disallow_untyped_defs = false # ๅ…่ฎธๆ— ็ฑปๅž‹ๅฎšไน‰ +ignore_missing_imports = true # ๅฟฝ็•ฅ็ผบๅคฑ็š„ๅฏผๅ…ฅ + +# ---------------------------------------------------------------------------- +# ๆต‹่ฏ•้…็ฝฎ (Pytest) +# ---------------------------------------------------------------------------- +[tool.pytest.ini_options] +# ๆต‹่ฏ•ๆ–‡ไปถๆœ็ดข่ทฏๅพ„ +testpaths = ["examples", "apps/tests"] +pythonpath = ["apps/src"] + +# ๆต‹่ฏ•ๆ–‡ไปถๅŒน้…ๆจกๅผ +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +# ๆ—ฅๅฟ—้…็ฝฎ +log_cli = true +log_cli_level = "INFO" + +# ๆต‹่ฏ•่ฟ่กŒๅ‚ๆ•ฐ +addopts = [ + "-v", # ่ฏฆ็ป†่พ“ๅ‡บ + "--tb=short", # ็ฎ€็Ÿญๅ›žๆบฏ +] + +# ๆต‹่ฏ•ๆ ‡่ฎฐๅฎšไน‰ +markers = [ + "examples: Example applications", + "slow: Slow tests (use -m 'not slow' to skip)", + "gpu: Tests requiring GPU", +] diff --git a/quickstart.sh b/quickstart.sh new file mode 100755 index 0000000..b362a90 --- /dev/null +++ b/quickstart.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# quickstart.sh โ€” sage-examples dev environment setup +# +# Usage: +# ./quickstart.sh # dev mode (default): hooks + .[dev] (includes [full]) +# ./quickstart.sh --full # optional backends only: .[full] +# ./quickstart.sh --standard # core deps only: no extras +# ./quickstart.sh --yes # non-interactive (assume yes) +# ./quickstart.sh --doctor # diagnose environment issues +# +# Install matrix: +# (default / --dev) pip install -e .[dev] โ† includes [full] via self-ref +# --full pip install -e .[full] +# --standard pip install -e . +# +# Rules: +# - NEVER creates a new venv. Must be called in an existing non-venv environment. +# - Installs hooks via direct copy from hooks/. + +set -e + +# โ”€โ”€โ”€ Colors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +# โ”€โ”€โ”€ Arguments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +EXTRAS="[dev]" # default โ€” dev includes [full] via pyproject self-reference +DOCTOR=false +for arg in "$@"; do + case "$arg" in + --doctor) DOCTOR=true ;; + --standard) EXTRAS="" ;; + --full) EXTRAS="[full]" ;; + --dev) EXTRAS="[dev]" ;; + --yes|-y) : ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR" + +echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" +echo -e "${BOLD}${BLUE} sage-examples โ€” Quick Start${NC}" +echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" +echo "" + +# โ”€โ”€โ”€ Doctor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +if [ "$DOCTOR" = true ]; then + echo -e "${BOLD}${BLUE}Environment Diagnosis${NC}" + echo "" + echo -e "${YELLOW}Python:${NC} $(python3 --version 2>/dev/null || echo 'NOT FOUND')" + echo -e "${YELLOW}Conda env:${NC} ${CONDA_DEFAULT_ENV:-none}" + echo -e "${YELLOW}Venv:${NC} ${VIRTUAL_ENV:-none}" + echo -e "${YELLOW}ruff:${NC} $(ruff --version 2>/dev/null || echo 'NOT FOUND')" + echo -e "${YELLOW}pytest:${NC} $(pytest --version 2>/dev/null || echo 'NOT FOUND')" + echo "" + echo -e "${YELLOW}Git hooks installed:${NC}" + for h in pre-commit pre-push post-commit; do + if [ -f "$PROJECT_ROOT/.git/hooks/$h" ]; then + echo -e " ${GREEN}โœ“ $h${NC}" + else + echo -e " ${RED}โœ— $h${NC}" + fi + done + exit 0 +fi + +# โ”€โ”€โ”€ Step 0: Require an active non-venv environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +if [ -n "$VIRTUAL_ENV" ]; then + echo -e "${RED} โŒ Detected Python venv: $VIRTUAL_ENV${NC}" + echo -e "${YELLOW} โ†’ This repository forbids venv/.venv usage.${NC}" + echo -e "${YELLOW} โ†’ Please deactivate the venv and use Conda or a system Python.${NC}" + exit 1 +fi + +# โ”€โ”€โ”€ Step 1/3: Python version check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo -e "${YELLOW}${BOLD}Step 1/3: Checking Python environment${NC}" +PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "unknown") +echo -e " Python version: ${CYAN}${PYTHON_VERSION}${NC}" +if python3 -c "import sys; exit(0 if sys.version_info >= (3,10) else 1)" 2>/dev/null; then + echo -e " ${GREEN}โœ“ Python โ‰ฅ 3.10${NC}" +else + echo -e " ${RED}โœ— Python 3.10+ required (found ${PYTHON_VERSION})${NC}" + exit 1 +fi +echo "" + +# โ”€โ”€โ”€ Step 2/3: Install Git Hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo -e "${YELLOW}${BOLD}Step 2/3: Installing Git hooks${NC}" +if [ -d "$PROJECT_ROOT/hooks" ]; then + installed=0 + for hook_src in "$PROJECT_ROOT/hooks"/*; do + hook_name=$(basename "$hook_src") + hook_dst="$PROJECT_ROOT/.git/hooks/$hook_name" + cp "$hook_src" "$hook_dst" + chmod +x "$hook_dst" + echo -e " ${GREEN}โœ“ $hook_name${NC}" + installed=$((installed + 1)) + done + echo -e "${GREEN}โœ“ $installed hook(s) installed${NC}" +else + echo -e "${YELLOW}โš  hooks/ directory not found โ€” skipping${NC}" +fi +echo "" + +# โ”€โ”€โ”€ Step 3/3: Install package โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +echo -e "${YELLOW}${BOLD}Step 3/3: Installing package (editable)${NC}" +if [ -n "$EXTRAS" ]; then + echo -e " ${CYAN}pip install -e .$EXTRAS${NC}" + pip install -e ".$EXTRAS" +else + echo -e " ${CYAN}pip install -e .${NC} (standard โ€” no extras)" + pip install -e . +fi +echo -e "${GREEN}โœ“ Package installed in editable mode${EXTRAS:+ with extras: $EXTRAS}${NC}" +echo "" + +echo -e "${GREEN}${BOLD}โœ“ Setup complete!${NC}" +echo "" +echo -e "${BLUE}${BOLD}Next steps:${NC}" +echo -e " ${CYAN}pytest tests/${NC} โ€” run tests" +echo -e " ${CYAN}ruff check src/${NC} โ€” lint" +echo -e " ${CYAN}./quickstart.sh --full${NC} โ€” reinstall with optional backends" +echo -e " ${CYAN}./quickstart.sh --standard${NC} โ€” install core deps only (no extras)" +echo -e " ${CYAN}./quickstart.sh --doctor${NC} โ€” diagnose environment" +echo "" diff --git a/tools/config/ruff.toml b/tools/config/ruff.toml new file mode 100644 index 0000000..fea4966 --- /dev/null +++ b/tools/config/ruff.toml @@ -0,0 +1,89 @@ +# sage-examples ็ปŸไธ€ไปฃ็ ่ดจ้‡้…็ฝฎ +# ๆ‰€ๆœ‰ๅญๅŒ…ๅบ”่ฏฅ็ปงๆ‰ฟ่ฟ™ไธช้…็ฝฎ +# ไฝฟ็”จๆ–นๆณ•๏ผšๅœจๅญๅŒ…็š„ pyproject.toml ไธญๆทปๅŠ ๏ผš +# [tool.ruff] +# extend = "../../tools/config/ruff.toml" +# +# ๆณจๆ„๏ผšruff.toml ๆ–‡ไปถไธไฝฟ็”จ [tool.xxx] ๅ‰็ผ€ +# ๅชๆœ‰ pyproject.toml ๆ‰้œ€่ฆ [tool.xxx] ๅ‰็ผ€ + +# ============================================================================ +# Ruff Configuration (ๆ›ฟไปฃ flake8, isort, pyupgrade ็ญ‰) +# ============================================================================ +target-version = "py310" # sage-examples supports Python 3.10+ +line-length = 100 + +# ๆŽ’้™ค็‰นๅฎš็›ฎๅฝ• +extend-exclude = [ + "build", + "_deps", + ".venv", + "venv", + ".sage", + "*.egg-info", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "dist", +] + +[lint] +# ๅฏ็”จ็š„่ง„ๅˆ™้›† +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort (import sorting) + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "C90", # mccabe complexity +] + +# ๅ…จๅฑ€ๅฟฝ็•ฅ็š„้”™่ฏฏ +ignore = [ + "E501", # line too long (handled by ruff formatter) + "B904", # raise-without-from-inside-except + "C901", # complex-structure (too-complex function) + "E402", # module-import-not-at-top-of-file + "B026", # star-arg-unpacking-after-keyword-arg + "B007", # unused-loop-control-variable + "B023", # function-uses-loop-variable + "B025", # duplicate-try-block-exception + "B018", # useless-expression + "F402", # import-shadowed-by-loop-var + "F405", # undefined-local-with-import-star + "UP035", # deprecated-import (typing.List, etc.) + "UP007", # non-pep604-annotation + "UP009", # utf-8-encoding-declaration (keep for compatibility) +] + +# ๆฏไธชๆ–‡ไปถ็š„ๅ…ทไฝ“ๅฟฝ็•ฅ่ง„ๅˆ™ +[lint.per-file-ignores] +"__init__.py" = ["F401", "F403"] # Unused imports in __init__.py are OK +"**/tests/**" = ["B018"] # Allow expressions in tests +"**/test_*.py" = ["B018"] +"**/*_test.py" = ["B018"] + +[lint.isort] +# Import ๆŽ’ๅบ้…็ฝฎ +known-first-party = ["sage"] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] + +[lint.mccabe] +# ๅœˆๅคๆ‚ๅบฆ้™ๅˆถ +max-complexity = 15 + +[format] +# ๆ ผๅผๅŒ–้…็ฝฎ +quote-style = "double" +indent-style = "space" +line-ending = "lf" +skip-magic-trailing-comma = false