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
+
+[](https://github.com/intellistream/sage-examples/actions)
+[](https://github.com/intellistream/sage-examples/actions)
+[](https://www.python.org/downloads/)
+[](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